mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 200a83a629 | |||
| d302aa1a00 | |||
| 9184994721 | |||
| d2676da789 | |||
| 6a6a7e2865 | |||
| 3ea8562c5e | |||
| e540832626 | |||
| 8dd583930e | |||
| 9bb18842e9 | |||
| 9cabc3532d | |||
| bdcfe3ff1d | |||
| dbfb689f6c | |||
| 17b4b9a32c | |||
| c8d668108a | |||
| dd084ec9e1 | |||
| bfbf5f9432 | |||
| facff1050e | |||
| de1479029d | |||
| 16766bdbc7 | |||
| d4fd760b0d | |||
| d437c21368 | |||
| a8b3b21fd0 | |||
| be55a49b04 | |||
| 293d660d6c | |||
| beca7eb126 | |||
| 1a0100cdd9 | |||
| 5267c96ab2 | |||
| 159f9913c1 | |||
| dfa9e094cd | |||
| 8cdefd4cd5 | |||
| 7b0a1b296c | |||
| 25a3dc501b | |||
| a3dfbaa0b0 | |||
| 58af8accfa | |||
| 4a989f6e2d | |||
| aae70053ee | |||
| 9b5e39844a | |||
| 1d30e4e1d2 | |||
| 1a5e961362 | |||
| ff609bc320 | |||
| 62a7f903f2 | |||
| 11c03fb0ab | |||
| 6f4a9d96fb | |||
| 4112189551 | |||
| 2bb24d1d2a | |||
| a1cb8d4d36 | |||
| a513304917 | |||
| d7f5b2435e | |||
| ce5762d873 | |||
| 831fa34752 | |||
| 11084a5963 | |||
| 2c4d197bf4 | |||
| 4ff8f3e14d | |||
| eac4d66adc | |||
| cb071bb93b | |||
| 0e500cacd3 | |||
| 1e4387f3ea | |||
| 9ba72e118b | |||
| add4a18961 | |||
| 17e42e7b94 | |||
| 812669e861 | |||
| c166c64b8e | |||
| 0b1e61504d | |||
| 7249623855 | |||
| 7b34d57b25 | |||
| aa24d9f071 | |||
| 319d360c85 | |||
| e10b0b7d22 | |||
| d1a773a684 | |||
| fe1e5f1b95 | |||
| 1e18ca9a46 | |||
| 5ef1dcba8e | |||
| e30f3c9ebe | |||
| 738d532ce6 | |||
| 434fbc2d73 | |||
| 302dd7e343 | |||
| d2086835c1 | |||
| 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
|
||||
+99
-51
@@ -5,58 +5,104 @@ 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.9:** this is a minor bump because the design file format moved to version 17, 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 about 90% of flat faces.** Sketches, offset planes and press-pull now
|
||||
attach to sheet metal rims and holes, plastic features (Boss, Lip, Rest, Snap Fit), lofts,
|
||||
revolved faces, pieces Offset Face moved and walls a Join merged
|
||||
([#677](https://github.com/bherbruck/solidcraft/pull/677), fixing
|
||||
[#681](https://github.com/bherbruck/solidcraft/issues/681)). Across the test models: sketch
|
||||
75% → 90%, offset plane 75% → 90%, press-pull 64% → 85%.
|
||||
- **Joins:** a logo-style part with pieces extruded up and down from one sketch now joins
|
||||
([#679](https://github.com/bherbruck/solidcraft/issues/679)), faces that only partly overlap
|
||||
fuse correctly ([#704](https://github.com/bherbruck/solidcraft/pull/704), fixing
|
||||
[#674](https://github.com/bherbruck/solidcraft/issues/674)), and booleans build the same body
|
||||
every time ([#748](https://github.com/bherbruck/solidcraft/pull/748)), so a design rebuilds
|
||||
identically on every run.
|
||||
- **Sketch:** type a dimension right after picking (D, click, type;
|
||||
[#695](https://github.com/bherbruck/solidcraft/pull/695)); redundant but consistent
|
||||
constraints are accepted, such as a rectangle clover
|
||||
([#699](https://github.com/bherbruck/solidcraft/pull/699)); tool windows for Scale, Offset,
|
||||
Mirror, Move/Copy, Fillet and Chamfer and the rectangular pattern (thanks @wdunn001), shown
|
||||
next to the Sketch Palette, which now lists the running tool's options; a typed Rho for the
|
||||
conic; a Construction Geometries row in the Sketch Palette that hides construction geometry
|
||||
([#804](https://github.com/bherbruck/solidcraft/pull/804)); sketch tool shortcuts open a sketch on the selected plane; Edit Sketch from the viewport
|
||||
menu.
|
||||
- **Features:** feature history on the left side with folders (opt in under Preferences;
|
||||
[#449](https://github.com/bherbruck/solidcraft/pull/449)); Convert to Sheet Metal from a solid
|
||||
or shelled body by its bend edges ([#725](https://github.com/bherbruck/solidcraft/pull/725));
|
||||
a tear corner relief ([#809](https://github.com/bherbruck/solidcraft/pull/809)); tapered and
|
||||
variable fillet radii ([#675](https://github.com/bherbruck/solidcraft/pull/675),
|
||||
[#749](https://github.com/bherbruck/solidcraft/pull/749)) with a radius spline you drag on the
|
||||
edge ([#752](https://github.com/bherbruck/solidcraft/pull/752)); free-form cut-outs stay exact
|
||||
in sheet-metal flat patterns ([#692](https://github.com/bherbruck/solidcraft/pull/692)) and edge
|
||||
flanges run along what cut-outs leave of an edge
|
||||
([#702](https://github.com/bherbruck/solidcraft/pull/702)); and Surface Extrude, sheets from
|
||||
open curves and profile walls ([#815](https://github.com/bherbruck/solidcraft/pull/815)).
|
||||
- **View:** Home and Fit frame only what is shown, and a middle double-click goes Home
|
||||
([#701](https://github.com/bherbruck/solidcraft/pull/701)); a cube navigator option
|
||||
(thanks @Karl-VonRichter, [#664](https://github.com/bherbruck/solidcraft/pull/664)); the window
|
||||
stays responsive and draggable while a design opens, with a Cancel button that stays put
|
||||
([#643](https://github.com/bherbruck/solidcraft/pull/643),
|
||||
[#686](https://github.com/bherbruck/solidcraft/pull/686)).
|
||||
- **Quality:** cuts no longer split walls they don't touch
|
||||
([#670](https://github.com/bherbruck/solidcraft/pull/670)); booleans are faster and use less
|
||||
memory ([#714](https://github.com/bherbruck/solidcraft/pull/714)); STL export welds shared
|
||||
vertices ([#816](https://github.com/bherbruck/solidcraft/pull/816)); complete Japanese
|
||||
translation ([#753](https://github.com/bherbruck/solidcraft/pull/753)).
|
||||
- **Fixes:** pasted features act on the pasted bodies (#613); a refused OK's error clears when
|
||||
you change the inputs (#641); dialogs show the units of their sizes (#630); the wheel over a
|
||||
dialog's list scrolls only the list (#678); a Single hole picked at a circle's centre lands on
|
||||
the centre (#657); axis or tangent plane on a drill tip works (#567); sketch text edits from
|
||||
letter interiors and moves as a whole; a projected edge stays the edge you picked; a size from
|
||||
a bare diameter keeps its unit (#716); Convert Mesh says how big a mesh is and what converts;
|
||||
the Save changes prompt takes arrow keys and Enter.
|
||||
- **MCP:** agent skills for modeling and editing over MCP
|
||||
([#728](https://github.com/bherbruck/solidcraft/pull/728)).
|
||||
- A new README demo and the SELENE rover showcase.
|
||||
|
||||
**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 17.** Designs are now format 17 (`solidcraft/17`). SolidCraft 0.8 and older can't
|
||||
open designs saved by 0.9, as a container or as JSON; they ask you to update instead. Keep a
|
||||
copy of a file you may still open in 0.8. Files from every earlier version still open in 0.9.
|
||||
|
||||
**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).
|
||||
**Upgrading from 0.8.** Nothing to do: open your designs and save (they're saved as format 17
|
||||
from then on). Feature-history folders on
|
||||
the left are off by default; turn them on in Preferences.
|
||||
|
||||
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 through-all holes along a wall or beside curved faces still fail
|
||||
([#559](https://github.com/bherbruck/solidcraft/issues/559)), as does a hole crossing an
|
||||
equal-radius hole ([#788](https://github.com/bherbruck/solidcraft/issues/788)).
|
||||
- Some fillets are still refused, such as along an outline of lines and arcs
|
||||
([#431](https://github.com/bherbruck/solidcraft/issues/431)).
|
||||
- Some faces still can't take an attached sketch: a revolve's flat faces before a later feature
|
||||
([#726](https://github.com/bherbruck/solidcraft/issues/726)) and faces after Split Body or
|
||||
Combine Intersect ([#565](https://github.com/bherbruck/solidcraft/issues/565)).
|
||||
- Fillet and chamfer accept an edge pick far from any edge and change the nearest one
|
||||
([#783](https://github.com/bherbruck/solidcraft/issues/783)); Press Pull fails on faces beside
|
||||
rounds ([#562](https://github.com/bherbruck/solidcraft/issues/562)).
|
||||
- `sheet.fold` refuses a fixed point exactly on the sheet face
|
||||
([#685](https://github.com/bherbruck/solidcraft/issues/685)), and some tabs on L-shaped or
|
||||
collinear blanks can't be folded ([#638](https://github.com/bherbruck/solidcraft/issues/638),
|
||||
[#639](https://github.com/bherbruck/solidcraft/issues/639)); 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.7 or earlier.** 0.8 changed the Linux app ID to `ai.storyteller.solidcraft`:
|
||||
`flatpak update` offers to replace the old app, and the `.deb`, `.rpm` and AppImage remove the old
|
||||
menu entry.
|
||||
|
||||
**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 +124,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 +138,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
|
||||
|
||||
+6
-3
@@ -37,8 +37,11 @@ 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.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.mp4 | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon | 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 +96,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
+19
-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.9.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.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3316,7 +3307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.6.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3331,7 +3322,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.6.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3347,7 +3338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.6.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3356,9 +3347,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.6.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -3371,11 +3362,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.6.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-geom",
|
||||
"spade",
|
||||
"thiserror 2.0.21",
|
||||
"truck-meshalgo",
|
||||
"truck-modeling",
|
||||
@@ -3386,7 +3380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.6.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3394,7 +3388,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.6.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3404,7 +3398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.6.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3415,7 +3409,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.6.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3430,7 +3424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.6.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4181,7 +4175,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 +4847,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+6
-2
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -37,10 +37,14 @@ truck-shapeops = { version = "0.4", default-features = false }
|
||||
truck-meshalgo = { version = "0.4", default-features = false, features = ["tessellation", "analyzers"] }
|
||||
truck-stepio = { version = "0.3", default-features = false }
|
||||
truck-topology = "0.6"
|
||||
# constrained Delaunay + Ruppert refinement for trimmed faces (MIT OR Apache-2.0); already in the
|
||||
# tree through truck-meshalgo.
|
||||
spade = { version = "2.15", default-features = false, features = ["std"] }
|
||||
rayon = "1.12"
|
||||
|
||||
# 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, the rim rounded in one pick, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots, "Pi 4" engraved in DejaVu Sans Bold and a PETG material defined in the design, with its mass in Properties, then height 24 → 32 mm in Parameters and it all rebuilds. 9× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.8.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
|
||||
|
||||
## Download
|
||||
|
||||
@@ -68,6 +68,16 @@ are listed by area in [docs/features.md](docs/features.md); status and plans are
|
||||
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues),
|
||||
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Built with SolidCraft
|
||||
|
||||
<a href="https://github.com/0xlillith/solidcraft-rover"><img src="docs/screenshots/rover-build.gif" alt="SELENE, a scientific rover by 0xlillith, assembling itself as the timeline plays through its 3,323 steps in SolidCraft: tub, wheels, electronics, suspension, sampling arm, solar wings, dish and camera turret" loading="lazy"></a>
|
||||
|
||||
*SELENE, a scientific rover built entirely through SolidCraft's MCP server by
|
||||
[@0xlillith](https://github.com/0xlillith/solidcraft-rover): 3,323 editable timeline steps that
|
||||
rebuild with no errors, replayed here at 2× speed.
|
||||
[Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/rover-build.mp4)
|
||||
· [download the editable model](https://github.com/0xlillith/solidcraft-rover/releases/tag/v1.0.0).*
|
||||
|
||||
## What works
|
||||
|
||||
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
|
||||
@@ -81,13 +91,17 @@ 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.
|
||||
step; with Sketch Dimension it's D, click, type, Enter. Fit keeps sketch geometry clear of the palette and navigation controls.
|
||||
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
|
||||
Newly captured complete adaptive boundaries retain their outer and hole source identities
|
||||
when spline span counts change. Partial or ambiguous boundaries do not qualify.
|
||||
@@ -108,7 +122,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
|
||||
|
||||
+65
-11
@@ -36,7 +36,7 @@ In scope, in priority order:
|
||||
metal rules (thickness, K-factor, bend radius) (M10).
|
||||
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
|
||||
|
||||
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need, PCB / 3D
|
||||
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D
|
||||
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Milestones
|
||||
@@ -49,27 +49,57 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace | deferred | — |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | 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, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
|
||||
## Render foundation
|
||||
|
||||
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
|
||||
bounded image/scratch admission checks, exposed by the read-only `render.backends`
|
||||
command. It describes the current CPU raster snapshot path without evaluating the model.
|
||||
Existing snapshot and viewport routes remain unchanged. The Render workspace, HDRI/PBR,
|
||||
render jobs and optional engines remain future work; see [the contract](docs/render-backends.md)
|
||||
and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620).
|
||||
|
||||
## 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
|
||||
stored references keep their saved meaning. Section Analysis uses the same check.
|
||||
- Sketch text can be edited from a selected outline's context menu or the Edit Text command.
|
||||
- Sketch text can be edited from a letter interior or outline's context menu or the Edit Text command.
|
||||
Interior selection requires every profile boundary to belong to the same text source;
|
||||
the editor highlights the whole source without replacing profile selections.
|
||||
Content, baseline X/Y, height and rotation update the original text source in one undo step;
|
||||
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
|
||||
use the document units captured when editing starts; generated outlines remain associated
|
||||
with their text source, and untouched inputs preserve their saved internal values.
|
||||
Dragging a text outline moves the entire source in one undo step; Escape restores the
|
||||
gesture. Move/Copy on a selected text outline provides unit-aware X/Y distances and rotation
|
||||
about its baseline; copies keep an independent editable text source.
|
||||
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
|
||||
waits for a distinct point without a degenerate-shape error or history entry. Typed input
|
||||
and programmatic commands retain their validation.
|
||||
@@ -145,6 +175,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 +187,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 +323,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 +357,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 +396,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)
|
||||
|
||||
@@ -514,7 +514,23 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
out.push(json!({"command": "solid.mirror", "params": p}));
|
||||
}
|
||||
Some("fillet") => {
|
||||
out.push(json!({"command": "solid.fillet", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
|
||||
let mut p = json!({"edges": edge_points(f), "radius": f.get("radius"), "name": name});
|
||||
// Constant radius (the default), chord length, or a radius varying along each edge
|
||||
// (`radius` at the end nearest `start`, `radius2` at the other, `mid` between,
|
||||
// `smooth`), or a B-spline along each edge (`radius` its first control radius,
|
||||
// `radii` the rest, `knots`); `fit` for a radius too large for the geometry.
|
||||
match f.get("type").and_then(Value::as_str) {
|
||||
Some("chord_length") => p["type"] = json!("chord"),
|
||||
Some("variable_radius") => p["type"] = json!("variable"),
|
||||
Some("spline_radius") => p["type"] = json!("spline"),
|
||||
_ => {}
|
||||
}
|
||||
for key in ["radius2", "start", "mid", "smooth", "fit", "radii", "knots"] {
|
||||
if let Some(v) = f.get(key) {
|
||||
p[key] = v.clone();
|
||||
}
|
||||
}
|
||||
out.push(json!({"command": "solid.fillet", "params": p}))
|
||||
}
|
||||
Some("chamfer") => {
|
||||
out.push(json!({"command": "solid.chamfer", "params": {"edges": edge_points(f), "distance": f.get("distance"), "name": name}}))
|
||||
|
||||
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,137 @@ 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); 1] = [("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 #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 {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! A parameter change gives the same topology as building at the new value from scratch, and
|
||||
//! changing it back gives the original topology: no stale splits, no faces added to regions
|
||||
//! nothing touched. Over the design corpus (`tests/corpus`, `tests/action_matrix/designs`), and
|
||||
//! the README demo's Raspberry Pi case pinned step by step.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::kernel::{FaceSurface, face_surface};
|
||||
|
||||
fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
/// A folder of its own for test `name` (tests run side by side).
|
||||
fn scratch_dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-rebuild-topology-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A body's topology: its face and edge counts, and how many faces lie in each plane.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Topology {
|
||||
faces: usize,
|
||||
edges: usize,
|
||||
planes: BTreeMap<[i64; 4], usize>,
|
||||
}
|
||||
|
||||
/// Every solid body's topology, by name.
|
||||
fn topology(s: &Session) -> BTreeMap<String, Topology> {
|
||||
let st = s.world_state();
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| !b.body.is_mesh())
|
||||
.map(|b| {
|
||||
let mut planes = BTreeMap::new();
|
||||
for i in 0..b.body.face_count() {
|
||||
if let Some(FaceSurface::Plane { normal, point }) = face_surface(&b.body, i) {
|
||||
let key = [normal.x, normal.y, normal.z, normal.dot(point) / 100.].map(|x| (x * 1e4).round() as i64);
|
||||
*planes.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
(b.name.clone(), Topology { faces: b.body.face_count(), edges: b.body.edge_count(), planes })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
s
|
||||
}
|
||||
|
||||
/// The design built from scratch: saved as it is (timeline only, no cached geometry) and
|
||||
/// opened again.
|
||||
fn rebuilt(s: &Session, path: &Path) -> Session {
|
||||
let mut t = s.scratch();
|
||||
t.execute("file.save", &json!({"path": path.to_string_lossy(), "format": "json"})).unwrap();
|
||||
open(path)
|
||||
}
|
||||
|
||||
fn errors(s: &Session) -> Vec<u64> {
|
||||
s.model.results.iter().filter(|r| r.error.is_some()).map(|r| r.id).collect()
|
||||
}
|
||||
|
||||
/// The corpus's designs, as sessions.
|
||||
fn designs() -> Vec<(String, Session)> {
|
||||
let root = root();
|
||||
let mut out = Vec::new();
|
||||
for dir in ["apps/solidcraft-cli/tests/corpus", "tests/action_matrix/designs"] {
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(root.join(dir))
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "solidcraft" || x == "json") && !p.to_string_lossy().ends_with(".expected.json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
for p in files {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let s = if p.extension().is_some_and(|x| x == "solidcraft") {
|
||||
open(&p)
|
||||
} else {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
|
||||
s.run_script(&script).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
if s.active_sketch.is_some() {
|
||||
let _ = s.execute("sketch.finish", &json!({}));
|
||||
}
|
||||
s
|
||||
};
|
||||
out.push((name, s));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For each design and its first length parameter that edits cleanly (the user's first): the parameter
|
||||
/// scaled by 1.1 gives what a fresh build at that value gives, and setting it back gives the
|
||||
/// original topology.
|
||||
#[test]
|
||||
fn a_parameter_edit_rebuilds_like_a_fresh_build_and_reverts_exactly() {
|
||||
let dir = scratch_dir("corpus");
|
||||
let mut problems = Vec::new();
|
||||
let mut checked = 0;
|
||||
for (name, s) in designs() {
|
||||
let original = topology(&s);
|
||||
let before = errors(&s);
|
||||
let mut params: Vec<_> = s.doc.params.iter().filter(|p| p.unit == "mm").cloned().collect();
|
||||
params.sort_by_key(|p| p.model);
|
||||
let mut tried = 0;
|
||||
for p in params {
|
||||
if tried == 1 {
|
||||
break;
|
||||
}
|
||||
let mut t = s.scratch();
|
||||
let expr = format!("({}) * 1.1", p.expr);
|
||||
if t.execute("parameters.change", &json!({"name": p.name, "expression": expr})).is_err() || errors(&t) != before {
|
||||
continue;
|
||||
}
|
||||
tried += 1;
|
||||
checked += 1;
|
||||
let edited = topology(&t);
|
||||
let fresh = topology(&rebuilt(&t, &dir.join(format!("{name}-{}.solidcraft", p.name))));
|
||||
if edited != fresh {
|
||||
problems.push(format!("{name}: {} = {expr}: edited {edited:?}\n fresh {fresh:?}", p.name));
|
||||
}
|
||||
t.execute("parameters.change", &json!({"name": p.name, "expression": p.expr})).unwrap();
|
||||
let back = topology(&t);
|
||||
if back != original {
|
||||
problems.push(format!("{name}: {} back to {}: {back:?}\n original {original:?}", p.name, p.expr));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(checked >= 8, "only {checked} parameter edits checked");
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
/// The README demo's Raspberry Pi case (`examples/demo/enclosure_ui_mcp.py --story pi`): its
|
||||
/// face count after each feature, and each outer side (the four walls and the bottom) one face
|
||||
/// throughout: (a) built at height 24, (b) built at 24 and changed to 32 in Parameters, (c)
|
||||
/// built at 32. The port cut-outs and the floor vents used to split the long wall (or the end
|
||||
/// wall at 24) into strips along the openings' tops and bottoms.
|
||||
#[test]
|
||||
fn pi_case_faces_per_step_at_both_heights() {
|
||||
// Sketch1 (1) .. RectangularPattern2 (14): the faces once features 1..=k are built.
|
||||
const FACES: [(usize, usize); 13] =
|
||||
[(2, 6), (3, 10), (4, 19), (5, 19), (6, 25), (7, 43), (8, 43), (9, 55), (10, 55), (11, 59), (12, 59), (13, 63), (14, 79)];
|
||||
let dir = scratch_dir("pi");
|
||||
let design: Value = serde_json::from_slice(&std::fs::read(root().join("tests/action_matrix/designs/pi-case.solidcraft")).unwrap()).unwrap();
|
||||
let at = |height: &str, file: &str| {
|
||||
let mut d = design.clone();
|
||||
for p in d["params"].as_array_mut().unwrap() {
|
||||
if p["name"] == "height" {
|
||||
p["expr"] = json!(height);
|
||||
}
|
||||
}
|
||||
let path = dir.join(file);
|
||||
std::fs::write(&path, serde_json::to_vec(&d).unwrap()).unwrap();
|
||||
open(&path)
|
||||
};
|
||||
let a = at("24 mm", "pi-24.solidcraft");
|
||||
let mut b = at("24 mm", "pi-24-to-32.solidcraft");
|
||||
b.execute("parameters.change", &json!({"name": "height", "expression": "32 mm"})).unwrap();
|
||||
let c = at("32 mm", "pi-32.solidcraft");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut per_case = Vec::new();
|
||||
for (case, s, h) in [("(a) at 24", a, 24.), ("(b) 24 changed to 32", b, 32.), ("(c) at 32", c, 32.)] {
|
||||
assert!(errors(&s).is_empty(), "{case}: features fail: {:?}", errors(&s));
|
||||
let mut steps = Vec::new();
|
||||
for (k, want) in FACES {
|
||||
let mut t = s.scratch();
|
||||
t.execute("timeline.rollback", &json!({"position": k})).unwrap();
|
||||
let st = t.world_state();
|
||||
let body = st.bodies.iter().find(|b| b.name == "Body1").unwrap();
|
||||
assert_eq!(body.body.face_count(), want, "{case}: faces after feature {k}");
|
||||
// Each outer side: the faces in its plane.
|
||||
for (n, d) in [([0., -1., 0.], 33.), ([0., 1., 0.], 33.), ([-1., 0., 0.], 47.5), ([1., 0., 0.], 47.5), ([0., 0., -1.], 0.)] {
|
||||
let on = (0..body.body.face_count())
|
||||
.filter(|&i| match face_surface(&body.body, i) {
|
||||
Some(FaceSurface::Plane { normal, point }) => {
|
||||
(normal.x - n[0]).abs() + (normal.y - n[1]).abs() + (normal.z - n[2]).abs() < 1e-6 && (normal.dot(point) - d).abs() < 1e-6
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.count();
|
||||
assert_eq!(on, 1, "{case}: after feature {k} the outer side facing {n:?} is {on} faces");
|
||||
}
|
||||
let bbox_top = body.mesh().bounds().max.z;
|
||||
assert!((bbox_top - h).abs() < 1e-6, "{case}: height {bbox_top}");
|
||||
steps.push(topology(&t));
|
||||
}
|
||||
per_case.push((case, steps));
|
||||
}
|
||||
// (b) and (c): the same topology after every feature.
|
||||
let (b, c) = (&per_case[1].1, &per_case[2].1);
|
||||
for (k, (x, y)) in b.iter().zip(c).enumerate() {
|
||||
assert_eq!(x, y, "after feature {}: (b) differs from (c)", k + 2);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -227,7 +227,7 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::Fillet { edges, style, .. } => {
|
||||
edges.iter_mut().for_each(pt);
|
||||
if let crate::FilletStyle::Variable { start, .. } = style {
|
||||
if let crate::FilletStyle::Variable { start, .. } | crate::FilletStyle::Spline { start, .. } = style {
|
||||
pt(start);
|
||||
}
|
||||
}
|
||||
@@ -263,8 +263,12 @@ impl FeatureKind {
|
||||
dir(direction);
|
||||
}
|
||||
FeatureKind::Thread { face, .. } => pt(face),
|
||||
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SheetConvert { face, .. } => pt(face),
|
||||
FeatureKind::SheetFlange { edges, .. } => edges.iter_mut().for_each(|e| pt(&mut e.point)),
|
||||
FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SheetConvert { face, bends, .. } => {
|
||||
pt(face);
|
||||
bends.iter_mut().for_each(pt);
|
||||
}
|
||||
FeatureKind::SheetFold { a, b, fixed, .. } => {
|
||||
pt(a);
|
||||
pt(b);
|
||||
@@ -317,6 +321,7 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::Revolve { .. }
|
||||
| FeatureKind::Extrude { .. }
|
||||
| FeatureKind::SurfaceExtrude { .. }
|
||||
| FeatureKind::Combine { .. }
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
|
||||
+79
-57
@@ -47,6 +47,7 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
FeatureKind::Extrude { sketch, .. }
|
||||
| FeatureKind::SurfaceExtrude { sketch, .. }
|
||||
| FeatureKind::Revolve { sketch, .. }
|
||||
| FeatureKind::Emboss { sketch, .. }
|
||||
| FeatureKind::SheetBase { sketch, .. }
|
||||
@@ -84,6 +85,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:")
|
||||
@@ -239,69 +244,19 @@ fn remap_link(
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Feature {
|
||||
/// Clone identities through typed reference fields. Sketch-local entity IDs stay local.
|
||||
pub fn remap_clone(
|
||||
&mut self,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
parameters: &BTreeMap<String, String>,
|
||||
) -> crate::Result<()> {
|
||||
impl FeatureKind {
|
||||
/// Point the body names a feature acts on (targets, tools, the body it rounds…) at the
|
||||
/// copies in `bodies`; names not in the map stay as they are.
|
||||
pub fn remap_bodies(&mut self, bodies: &BTreeMap<String, String>) {
|
||||
use FeatureKind as K;
|
||||
match &self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
checked_plane(plane, 0)?;
|
||||
for link in &sketch.links {
|
||||
checked_link(&link.source, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPlane { plane }
|
||||
| K::Split { plane, .. }
|
||||
| K::SplitFace { plane, .. }
|
||||
| K::Mirror { plane, .. }
|
||||
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
|
||||
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
|
||||
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPoint { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.kind.remap_refs(ids, names);
|
||||
for n in &mut self.body_names {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.participants {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.param_names {
|
||||
mapped_name(n, parameters);
|
||||
}
|
||||
for n in self.face_names.iter_mut().chain(&mut self.edge_names) {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
for (_, e, _) in self.kind.inputs_mut() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
for e in self.kind.point_expr_mut().into_iter().flatten() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
match &mut self.kind {
|
||||
match self {
|
||||
K::Emboss { face: Some(face), .. } => mapped_name(&mut face.body, bodies),
|
||||
K::Split { tool: Some(tool), .. } | K::SplitFace { tool: Some(tool), .. } | K::SurfaceTrim { tool: Some(tool), .. } => {
|
||||
mapped_name(&mut tool.body, bodies)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match &mut self.kind {
|
||||
match self {
|
||||
K::Extrude { targets, .. }
|
||||
| K::Revolve { targets, .. }
|
||||
| K::Loft { targets, .. }
|
||||
@@ -369,6 +324,65 @@ impl crate::Feature {
|
||||
| K::MeshConvert { body, .. } => mapped_name(body, bodies),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Feature {
|
||||
/// Clone identities through typed reference fields. Sketch-local entity IDs stay local.
|
||||
pub fn remap_clone(
|
||||
&mut self,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
parameters: &BTreeMap<String, String>,
|
||||
) -> crate::Result<()> {
|
||||
use FeatureKind as K;
|
||||
match &self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
checked_plane(plane, 0)?;
|
||||
for link in &sketch.links {
|
||||
checked_link(&link.source, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPlane { plane }
|
||||
| K::Split { plane, .. }
|
||||
| K::SplitFace { plane, .. }
|
||||
| K::Mirror { plane, .. }
|
||||
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
|
||||
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
|
||||
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPoint { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.kind.remap_refs(ids, names);
|
||||
for n in &mut self.body_names {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.participants {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.param_names {
|
||||
mapped_name(n, parameters);
|
||||
}
|
||||
for n in self.face_names.iter_mut().chain(&mut self.edge_names) {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
for (_, e, _) in self.kind.inputs_mut() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
for e in self.kind.point_expr_mut().into_iter().flatten() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
self.kind.remap_bodies(bodies);
|
||||
match &mut self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
remap_plane(plane, ids, names, bodies, 0)?;
|
||||
@@ -379,7 +393,7 @@ impl crate::Feature {
|
||||
}
|
||||
for link in &mut sketch.links {
|
||||
remap_link(&mut link.source, ids, names, bodies, 0);
|
||||
if let Some(n) = &mut link.face_name {
|
||||
for n in link.face_name.iter_mut().chain(&mut link.edge_name) {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
}
|
||||
@@ -464,6 +478,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,67 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`find`](Self::find), else the one sketch line a generated straight segment runs along
|
||||
/// and overlaps: a profile a feature grew to overlap a flush face (its neighbours lengthened
|
||||
/// past the sketch's corners) still names its sides by the lines they were drawn on. Two
|
||||
/// candidates stay ambiguous.
|
||||
pub(crate) fn find_along(&self, generated: &Seg2) -> Option<String> {
|
||||
if let Some(id) = self.find(generated) {
|
||||
return Some(id);
|
||||
}
|
||||
let Seg2::Line { a: c, b: d } = *generated else { return None };
|
||||
let mut found: Option<Option<&str>> = None;
|
||||
for (id, linked, originals) in &self.segments {
|
||||
if *linked {
|
||||
continue;
|
||||
}
|
||||
let along = originals.iter().any(|original| {
|
||||
let Seg2::Line { a, b } = *original else { return false };
|
||||
let direction = b - a;
|
||||
let scale = direction.len().max((d - c).len()).max(1.);
|
||||
if direction.len() <= 1e-12
|
||||
|| direction.cross(c - a).abs() > 1e-10 * scale * scale
|
||||
|| direction.cross(d - a).abs() > 1e-10 * scale * scale
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (tc, td) = ((c - a).dot(direction) / direction.len2(), (d - a).dot(direction) / direction.len2());
|
||||
tc.min(td).max(0.) < tc.max(td).min(1.) - 1e-9
|
||||
});
|
||||
if along {
|
||||
found = Some(if found.is_some() { None } else { Some(*id) });
|
||||
}
|
||||
}
|
||||
found.flatten().map(str::to_owned)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,7 +509,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 +580,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 +597,188 @@ 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 Split Face split `body`'s faces on `plane` (a split line lies on it).
|
||||
fn split_on_purpose(body: &ModelBody, plane: &solidcraft_geom::Plane) -> bool {
|
||||
let tolerance = 1e-6 * (1. + body.body.size());
|
||||
let on_plane =
|
||||
|line: &Vec<solidcraft_geom::Vec3>| !line.is_empty() && line.iter().all(|p| (*p - plane.origin).dot(plane.normal()).abs() <= tolerance);
|
||||
body.body.split_lines().is_some_and(|lines| lines.iter().any(on_plane))
|
||||
}
|
||||
|
||||
/// 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). Where
|
||||
/// the face's pieces no longer share a plane (Offset Face moved one of them), the piece by
|
||||
/// its own name: the face's name qualified by the faces around it (`F2:top#[F2:side:l4,…]`).
|
||||
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 full = names.faces.get(index)?;
|
||||
let stem = crate::naming::strip_piece(full);
|
||||
let origin = stem.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
|
||||
let inverse = crate::mat_inverse(&body.source_transform)?;
|
||||
let mut candidates = vec![stem];
|
||||
if full != stem {
|
||||
candidates.push(full);
|
||||
}
|
||||
candidates.into_iter().find_map(|name| {
|
||||
let target = crate::config::ConfigFaceTarget {
|
||||
body: body.definition_name().into(),
|
||||
origin_feature: origin,
|
||||
name: name.to_string(),
|
||||
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);
|
||||
// `face` is `name` itself or a piece of it (a piece of a piece, …).
|
||||
let piece_of = |face: &str| {
|
||||
let mut n = face;
|
||||
loop {
|
||||
if n == name {
|
||||
return true;
|
||||
}
|
||||
match crate::piece_names::parent_piece(n) {
|
||||
Some(parent) => n = parent,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut indices: Vec<usize> = names.faces.iter().enumerate().filter(|(_, face)| piece_of(face)).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))?;
|
||||
// A piece by its own name stands for itself, which a piece of a face split on purpose
|
||||
// (Split Face) never does: that needs a fixed plane.
|
||||
if crate::naming::strip_piece(name) != name
|
||||
&& let Some(plane) = indices.first().and_then(|index| body.body.planar_face(*index))
|
||||
&& split_on_purpose(body, &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(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)
|
||||
&& split_on_purpose(body, &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 +798,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 +848,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 { .. }))))
|
||||
}
|
||||
@@ -123,24 +124,33 @@ impl Document {
|
||||
target.validate()?;
|
||||
let prefix = format!("F{}:", target.origin_feature);
|
||||
let role = target.name.strip_prefix(&prefix).ok_or_else(|| DocError::Invalid("face lineage does not match its feature".into()))?;
|
||||
if crate::naming::strip_piece(&target.name) != target.name {
|
||||
// An exact history name may name a piece by the faces around it (`F2:top#[F2:side:l4,…]`),
|
||||
// never by a number (an older design's piece, or a tie the faces around it left).
|
||||
let numbered =
|
||||
target.name.match_indices('#').any(|(i, _)| target.name.get(i + 1..).is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())));
|
||||
let lineage_piece = matches!(target.certificate, Some(super::FaceCertificate::Lineage)) && !numbered;
|
||||
if crate::naming::strip_piece(&target.name) != target.name && !lineage_piece {
|
||||
return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into()));
|
||||
}
|
||||
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
|
||||
if let Some(certificate) = &target.certificate {
|
||||
let valid = match (certificate, &feature.kind) {
|
||||
(super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => {
|
||||
(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 +191,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 +205,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 +221,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 +247,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 +259,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 +268,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> =
|
||||
|
||||
+363
-14
@@ -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")]
|
||||
@@ -714,12 +782,17 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
refold: bool,
|
||||
},
|
||||
/// Convert a plate body (constant thickness from the face at `face`) to sheet metal.
|
||||
/// Convert a body to sheet metal from its fixed face at `face`: a plate of constant
|
||||
/// thickness or, with `bends`, a solid or shelled body whose faces reached from the fixed
|
||||
/// face across those edges become the sheet.
|
||||
SheetConvert {
|
||||
body: String,
|
||||
face: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
rule: Option<String>,
|
||||
/// Points on the sharp edges that become bends.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
bends: Vec<Vec3>,
|
||||
},
|
||||
/// Plastic boss: a round post on a face (draft, root fillet, hole, ribs).
|
||||
Boss {
|
||||
@@ -852,6 +925,18 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
/// A surface body swept straight along the sketch normal from open curve chains and the
|
||||
/// boundaries of profiles: walls only, no caps.
|
||||
SurfaceExtrude {
|
||||
sketch: u64,
|
||||
/// Profiles whose boundary loops are swept (none: only `curves`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
profiles: Option<ProfileSel>,
|
||||
/// Sketch curve ids, grouped into chains of curves that meet end to end.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
curves: Vec<String>,
|
||||
extent: Extent,
|
||||
},
|
||||
/// A planar surface body extended outward by `distance` past the edges through `edges`.
|
||||
SurfaceExtend {
|
||||
body: String,
|
||||
@@ -874,6 +959,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.
|
||||
@@ -917,16 +1006,23 @@ pub enum FilletStyle {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
smooth: bool,
|
||||
},
|
||||
/// The radius along each edge as a clamped B-spline of the position from the end nearest
|
||||
/// `start` (0) to the other (1): `radius` is its first control radius and `radii` the rest,
|
||||
/// its degree what `knots` leave (knots = control radii + degree + 1).
|
||||
Spline { start: Vec3, knots: Vec<f64>, radii: Vec<String> },
|
||||
}
|
||||
|
||||
/// A fillet radius too large for the geometry around its edges: refused (`Exact`, the default),
|
||||
/// or reduced to the largest that works for all of them (`Reduce`), with a warning.
|
||||
/// reduced to the largest that works for all of them (`Reduce`), or tapered along each edge it
|
||||
/// doesn't fit to the room there, down to zero where a face closes to a point (`Taper`); a
|
||||
/// warning says what was done.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FilletFit {
|
||||
#[default]
|
||||
Exact,
|
||||
Reduce,
|
||||
Taper,
|
||||
}
|
||||
|
||||
impl FilletFit {
|
||||
@@ -939,6 +1035,13 @@ impl FilletStyle {
|
||||
pub fn is_constant(&self) -> bool {
|
||||
*self == FilletStyle::Constant
|
||||
}
|
||||
|
||||
/// The spline of a [`FilletStyle::Spline`] with its control radii `values` (the first is
|
||||
/// the feature's `radius`).
|
||||
pub fn spline(knots: &[f64], values: Vec<f64>) -> solidcraft_kernel::RadiusSpline {
|
||||
let degree = knots.len().saturating_sub(values.len() + 1);
|
||||
solidcraft_kernel::RadiusSpline { degree, knots: knots.to_vec(), radii: values }
|
||||
}
|
||||
}
|
||||
|
||||
/// A face picked by a point on it, on a named body.
|
||||
@@ -1134,6 +1237,7 @@ impl FeatureKind {
|
||||
use FeatureKind as K;
|
||||
match self {
|
||||
K::Extrude { sketch, .. }
|
||||
| K::SurfaceExtrude { sketch, .. }
|
||||
| K::Revolve { sketch, .. }
|
||||
| K::Emboss { sketch, .. }
|
||||
| K::Rib { sketch, .. }
|
||||
@@ -1265,6 +1369,7 @@ impl FeatureKind {
|
||||
FeatureKind::Align { .. } => "AlignFeature",
|
||||
FeatureKind::Remove { .. } => "RemoveFeature",
|
||||
FeatureKind::Patch { .. } => "PatchFeature",
|
||||
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrudeFeature",
|
||||
FeatureKind::Stitch { .. } => "StitchFeature",
|
||||
FeatureKind::Thicken { .. } => "ThickenFeature",
|
||||
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
|
||||
@@ -1331,6 +1436,7 @@ impl FeatureKind {
|
||||
FeatureKind::Align { .. } => "Align",
|
||||
FeatureKind::Remove { .. } => "Remove",
|
||||
FeatureKind::Patch { .. } => "Patch",
|
||||
FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrude",
|
||||
FeatureKind::Stitch { .. } => "Stitch",
|
||||
FeatureKind::Thicken { .. } => "Thicken",
|
||||
FeatureKind::SurfaceTrim { .. } => "Trim",
|
||||
@@ -1351,7 +1457,9 @@ impl FeatureKind {
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles, .. }
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sk), profiles, .. } => vec![(*sk, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sk), profiles, .. } | FeatureKind::SurfaceExtrude { sketch: sk, profiles: Some(profiles), .. } => {
|
||||
vec![(*sk, profiles)]
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
@@ -1365,7 +1473,9 @@ impl FeatureKind {
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles, .. }
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } | FeatureKind::SurfaceExtrude { sketch, profiles: Some(profiles), .. } => {
|
||||
vec![(*sketch, profiles)]
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => {
|
||||
sections.iter().filter(|section| section.point.is_none()).map(|section| (section.sketch, §ion.profiles)).collect()
|
||||
}
|
||||
@@ -1388,6 +1498,8 @@ impl FeatureKind {
|
||||
FeatureKind::Thread { face, .. } => vec![face],
|
||||
FeatureKind::Emboss { face: Some(face), .. } => vec![&mut face.at],
|
||||
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
|
||||
// With bend edges the fixed face is found by name (a plate keeps its point).
|
||||
FeatureKind::SheetConvert { face, bends, .. } if !bends.is_empty() => vec![face],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1411,7 +1523,7 @@ impl FeatureKind {
|
||||
| FeatureKind::ConstructionPlane { plane }
|
||||
| FeatureKind::Split { plane, .. }
|
||||
| FeatureKind::SplitFace { plane, .. } => plane_exprs(plane, &mut v),
|
||||
FeatureKind::Extrude { extent, .. } => {
|
||||
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
|
||||
v.push(&extent.distance);
|
||||
if let Some(d) = &extent.distance2 {
|
||||
v.push(d);
|
||||
@@ -1432,9 +1544,13 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::Fillet { radius, style, .. } => {
|
||||
v.push(radius);
|
||||
if let FilletStyle::Variable { radius2, mid, .. } = style {
|
||||
v.push(radius2);
|
||||
v.extend(mid.iter().map(|(_, r)| r.as_str()));
|
||||
match style {
|
||||
FilletStyle::Variable { radius2, mid, .. } => {
|
||||
v.push(radius2);
|
||||
v.extend(mid.iter().map(|(_, r)| r.as_str()));
|
||||
}
|
||||
FilletStyle::Spline { radii, .. } => v.extend(radii.iter().map(String::as_str)),
|
||||
FilletStyle::Constant | FilletStyle::Chord => {}
|
||||
}
|
||||
}
|
||||
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
|
||||
@@ -1521,9 +1637,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 +1742,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 +1787,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 +1805,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,
|
||||
@@ -1684,12 +1821,48 @@ pub struct Document {
|
||||
/// Browser groups (folders of bodies, sketches or construction planes).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub browser_groups: Vec<BrowserGroup>,
|
||||
/// Named folders used to organise features in the timeline. They do not affect evaluation
|
||||
/// order; feature ids remain the source of truth when features are renamed or reordered.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub timeline_groups: Vec<TimelineGroup>,
|
||||
/// Browser item order per component folder (`"<component>/<folder>"` → item keys).
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub browser_order: std::collections::BTreeMap<String, Vec<String>>,
|
||||
/// 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).
|
||||
@@ -1718,6 +1891,66 @@ pub struct BrowserGroup {
|
||||
pub items: Vec<String>,
|
||||
}
|
||||
|
||||
/// A saved feature-history folder. Folders may nest, and a feature can belong to at most one folder.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TimelineGroup {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// The containing feature folder. `None` places the folder at the timeline root.
|
||||
#[serde(default)]
|
||||
pub parent: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub features: Vec<u64>,
|
||||
}
|
||||
|
||||
/// The first feature-history folder whose own and nested features do not form one
|
||||
/// uninterrupted run in the timeline.
|
||||
pub fn non_contiguous_timeline_group(features: &[Feature], groups: &[TimelineGroup]) -> Option<u64> {
|
||||
let positions: std::collections::BTreeMap<u64, usize> = features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
|
||||
let mut stats = std::collections::BTreeMap::new();
|
||||
let mut children = std::collections::BTreeMap::new();
|
||||
for group in groups {
|
||||
let mut count = 0usize;
|
||||
let mut first = usize::MAX;
|
||||
let mut last = 0usize;
|
||||
for index in group.features.iter().filter_map(|feature| positions.get(feature)) {
|
||||
count = count.saturating_add(1);
|
||||
first = first.min(*index);
|
||||
last = last.max(*index);
|
||||
}
|
||||
stats.insert(group.id, (count, first, last));
|
||||
children.entry(group.id).or_insert(0usize);
|
||||
if let Some(parent) = group.parent {
|
||||
let child_count = children.entry(parent).or_insert(0);
|
||||
*child_count = child_count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
let parents: std::collections::BTreeMap<u64, Option<u64>> = groups.iter().map(|group| (group.id, group.parent)).collect();
|
||||
let mut pending: Vec<u64> = groups.iter().filter(|group| children.get(&group.id) == Some(&0)).map(|group| group.id).collect();
|
||||
while let Some(id) = pending.pop() {
|
||||
let (count, first, last) = stats.get(&id).copied().unwrap_or_default();
|
||||
if count > 0 && last.saturating_sub(first).saturating_add(1) != count {
|
||||
return Some(id);
|
||||
}
|
||||
if let Some(parent) = parents.get(&id).copied().flatten() {
|
||||
if let Some((parent_count, parent_first, parent_last)) = stats.get_mut(&parent) {
|
||||
*parent_count = parent_count.saturating_add(count);
|
||||
if count > 0 {
|
||||
*parent_first = (*parent_first).min(first);
|
||||
*parent_last = (*parent_last).max(last);
|
||||
}
|
||||
}
|
||||
if let Some(remaining) = children.get_mut(&parent) {
|
||||
*remaining = remaining.saturating_sub(1);
|
||||
if *remaining == 0 {
|
||||
pending.push(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn default_format() -> String {
|
||||
crate::format::format_string(crate::format::FORMAT)
|
||||
}
|
||||
@@ -1748,16 +1981,20 @@ 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(),
|
||||
param_comments: Default::default(),
|
||||
browser_groups: Vec::new(),
|
||||
timeline_groups: Vec::new(),
|
||||
browser_order: Default::default(),
|
||||
named_views: Vec::new(),
|
||||
visibility: Visibility::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1790,7 +2027,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.
|
||||
@@ -1900,6 +2176,40 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// The deepest timeline folder spanning the insertion slot between `marker - 1` and
|
||||
/// `marker`. A slot at a folder's outer boundary is not inside that folder.
|
||||
fn timeline_group_at_insertion(&self, marker: usize) -> Option<u64> {
|
||||
let before = self.features.get(marker.checked_sub(1)?)?.id;
|
||||
let after = self.features.get(marker)?.id;
|
||||
let direct = |feature| self.timeline_groups.iter().find(|group| group.features.contains(&feature)).map(|group| group.id);
|
||||
let parents: std::collections::BTreeMap<u64, Option<u64>> = self.timeline_groups.iter().map(|group| (group.id, group.parent)).collect();
|
||||
|
||||
let mut before_ancestors = std::collections::BTreeSet::new();
|
||||
let mut current = direct(before);
|
||||
let mut steps = 0usize;
|
||||
while let Some(id) = current {
|
||||
if !before_ancestors.insert(id) || steps >= self.timeline_groups.len() {
|
||||
return None;
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
steps = steps.saturating_add(1);
|
||||
}
|
||||
|
||||
current = direct(after);
|
||||
steps = 0;
|
||||
while let Some(id) = current {
|
||||
if before_ancestors.contains(&id) {
|
||||
return Some(id);
|
||||
}
|
||||
if steps >= self.timeline_groups.len() {
|
||||
return None;
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
steps = steps.saturating_add(1);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Insert a feature at the marker (or the end) and return its id.
|
||||
pub fn add_feature(&mut self, kind: FeatureKind, name: Option<&str>) -> Result<u64> {
|
||||
if self.features.len() >= MAX_FEATURES {
|
||||
@@ -1931,6 +2241,7 @@ impl Document {
|
||||
shell_piece_order: false,
|
||||
kind,
|
||||
};
|
||||
let insertion_group = self.marker.and_then(|marker| self.timeline_group_at_insertion(marker));
|
||||
match self.marker {
|
||||
Some(m) if m < self.features.len() => {
|
||||
self.features.insert(m, f);
|
||||
@@ -1938,6 +2249,14 @@ impl Document {
|
||||
}
|
||||
_ => self.features.push(f),
|
||||
}
|
||||
if let Some(group_id) = insertion_group {
|
||||
let positions: std::collections::BTreeMap<u64, usize> =
|
||||
self.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
|
||||
if let Some(group) = self.timeline_groups.iter_mut().find(|group| group.id == group_id) {
|
||||
group.features.push(id);
|
||||
group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
self.name_feature_inputs(id);
|
||||
Ok(id)
|
||||
}
|
||||
@@ -1952,6 +2271,9 @@ impl Document {
|
||||
.rules
|
||||
.retain(|rule| !rule.references_features(&gone) && !rule.parameter_references().iter().any(|name| gone_params.contains(name)));
|
||||
self.features.retain(|f| !gone.contains(&f.id));
|
||||
for group in &mut self.timeline_groups {
|
||||
group.features.retain(|feature| !gone.contains(feature));
|
||||
}
|
||||
// Configuration columns of deleted features go with them.
|
||||
let keep: Vec<bool> = self
|
||||
.configs
|
||||
@@ -2210,18 +2532,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 +2597,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)
|
||||
}
|
||||
|
||||
+497
-122
File diff suppressed because it is too large
Load Diff
+234
-64
@@ -300,6 +300,17 @@ fn replace_sheet_body(mb: &mut ModelBody, body: Body) {
|
||||
*mb = next;
|
||||
}
|
||||
|
||||
/// Put a converted sheet in the place of the body it was made from.
|
||||
fn put_sheet(st: &mut ModelState, sh: sheet::SheetBody) -> Result<()> {
|
||||
let solid = sh.solid()?;
|
||||
if let Some(b) = st.bodies.iter_mut().find(|b| b.name == sh.body) {
|
||||
replace_sheet_body(b, solid);
|
||||
}
|
||||
st.sheets.retain(|s| s.body != sh.body);
|
||||
st.sheets.push(sh);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> {
|
||||
let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
let solid = sheet.solid()?;
|
||||
@@ -325,20 +336,32 @@ fn sheet_for(st: &ModelState, body: &Option<String>, pick: Option<Vec3>) -> Resu
|
||||
.ok_or_else(|| DocError::Invalid("there is no sheet metal body".into()))
|
||||
}
|
||||
|
||||
/// A region mirrored in the sketch x axis (y → −y); curves other than lines and arcs as polylines.
|
||||
/// 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), exactly: free-form curves through their
|
||||
/// control points.
|
||||
fn mirror_y(r: &Region2) -> Region2 {
|
||||
let f = |p: Vec2| Vec2::new(p.x, -p.y);
|
||||
let m = |l: &Loop2| {
|
||||
let segs: Vec<Seg2> = l
|
||||
.segs
|
||||
.iter()
|
||||
.flat_map(|s| match *s {
|
||||
Seg2::Line { a, b } => vec![Seg2::Line { a: f(a), b: f(b) }],
|
||||
Seg2::Arc { center, radius, start, sweep } => vec![Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep }],
|
||||
other => {
|
||||
let pts: Vec<Vec2> = (0..=16).map(|k| f(other.point_at(k as f64 / 16.0))).collect();
|
||||
pts.windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect()
|
||||
}
|
||||
.map(|s| match *s {
|
||||
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep },
|
||||
other => sheet::map_controls(&other, f),
|
||||
})
|
||||
.collect();
|
||||
Loop2 { segs }.ccw()
|
||||
@@ -415,8 +438,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 +464,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 +523,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)
|
||||
}
|
||||
@@ -503,7 +557,34 @@ pub(super) fn sheet_eval(
|
||||
}
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
FeatureKind::SheetConvert { body, face, rule } => {
|
||||
// With bend edges: a solid or shelled body whose faces become the sheet.
|
||||
FeatureKind::SheetConvert { body, face, rule, bends } if !bends.is_empty() => {
|
||||
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let rv = doc.rule_values(vals, &r)?;
|
||||
// The bend edges by name first; a name that is gone keeps its point, with a warning.
|
||||
let mut picks = Vec::with_capacity(bends.len());
|
||||
for (i, p) in bends.iter().enumerate() {
|
||||
let name = f.edge_names.get(i).filter(|n| !n.is_empty());
|
||||
picks.push(match name.map(|n| (n, crate::naming::locate_edge(&mb, n, *p))) {
|
||||
Some((n, Ok(hit))) => {
|
||||
if let Some(note) = hit.note(n) {
|
||||
append_warning(warning, note);
|
||||
}
|
||||
hit.point
|
||||
}
|
||||
Some((n, Err(_))) => {
|
||||
append_warning(warning, format!("edge `{n}` no longer exists; using the edge at the picked point"));
|
||||
*p
|
||||
}
|
||||
None => *p,
|
||||
});
|
||||
}
|
||||
let mut sh = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
|
||||
tag_flanges(&mut sh, 0, f.id);
|
||||
put_sheet(st, sh)
|
||||
}
|
||||
FeatureKind::SheetConvert { body, face, rule, .. } => {
|
||||
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?;
|
||||
let tol = (mb.body.size() * 1e-3).max(1e-3);
|
||||
@@ -522,20 +603,16 @@ pub(super) fn sheet_eval(
|
||||
let area = region.area().abs();
|
||||
let vol = kernel::measure(&mb.body)?.volume;
|
||||
if (area * t - vol).abs() > 1e-3 * vol.max(1.0) {
|
||||
return Err(DocError::Invalid("not supported yet: converting bodies that are not a plate of even thickness".into()));
|
||||
return Err(DocError::Invalid(
|
||||
"not supported yet: converting bodies that are not a plate of even thickness without bend edges (`bends`)".into(),
|
||||
));
|
||||
}
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let mut rv = doc.rule_values(vals, &r)?;
|
||||
rv.thickness = t;
|
||||
let mut sh = sheet::base_flange(body, &r.name, &rv, plane, Region2 { outer: region.outer.clone(), holes: Vec::new() });
|
||||
sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
|
||||
let solid = sh.solid()?;
|
||||
if let Some(b) = st.bodies.iter_mut().find(|b| &b.name == body) {
|
||||
replace_sheet_body(b, solid);
|
||||
}
|
||||
st.sheets.retain(|s| &s.body != body);
|
||||
st.sheets.push(sh);
|
||||
Ok(())
|
||||
put_sheet(st, sh)
|
||||
}
|
||||
_ => Err(DocError::Invalid(format!("{} is not a sheet metal feature", f.name))),
|
||||
}
|
||||
@@ -622,24 +699,113 @@ 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`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
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 +816,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 +836,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 +844,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 +872,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 +906,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 +914,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 +975,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
|
||||
@@ -983,11 +1149,12 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
let base = Plane::new(*position - z * delta, x, z.cross(x)).ok_or_else(|| DocError::Invalid("boss frame".into()))?;
|
||||
let r_in = r0 + delta * dr.tan();
|
||||
let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, r_in), holes: Vec::new() };
|
||||
let mut tools = if dr.abs() > 1e-12 {
|
||||
let post = if dr.abs() > 1e-12 {
|
||||
vec![kernel::extrude_tapered(&base, &disc, h + delta, 1.0, -dr)?]
|
||||
} else {
|
||||
kernel::extrude(&base, &[disc], 0.0, h + delta)?
|
||||
};
|
||||
let mut tools: Vec<Traced> = post.into_iter().map(|b| crate::lineage::part_tool(b, "post")).collect();
|
||||
if let Some(n) = ribs {
|
||||
let n = val(vals, n, Kind::Unitless)?.round();
|
||||
if !(0.0..=64.0).contains(&n) {
|
||||
@@ -1022,19 +1189,19 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
]),
|
||||
holes: Vec::new(),
|
||||
};
|
||||
tools.extend(kernel::extrude(&rp, &[rect], -delta, hr)?);
|
||||
tools.extend(kernel::extrude(&rp, &[rect], -delta, hr)?.into_iter().map(|b| crate::lineage::part_tool(b, &format!("rib{k}"))));
|
||||
}
|
||||
}
|
||||
apply_op(st, f, tools, Operation::Join, std::slice::from_ref(&target))?;
|
||||
apply_traced(st, f, tools, Operation::Join, std::slice::from_ref(&target))?;
|
||||
// The root fillet: rounds the circle where the post meets the face.
|
||||
if rf > 1e-9 {
|
||||
let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Invalid("boss body".into()))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("boss body".into())) };
|
||||
let mut warn = None;
|
||||
let edges = resolve_edges(&mb.body, &[*position + x * r0], &[], &mut warn)?;
|
||||
let nb = kernel::fillet(&mb.body, &edges, rf)?;
|
||||
let nb = mb.traced().then(kernel::fillet(&mb.body, &edges, rf)?);
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, nb, mb.feature);
|
||||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||||
}
|
||||
}
|
||||
if let Some(hd) = hole_diameter {
|
||||
@@ -1048,7 +1215,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
}
|
||||
let hp = Plane::new(*position, x, z.cross(x)).ok_or_else(|| DocError::Invalid("hole frame".into()))?;
|
||||
let tool = kernel::extrude(&hp, &[Region2 { outer: Loop2::circle(Vec2::ZERO, rh), holes: Vec::new() }], h - depth, h + delta)?;
|
||||
apply_op(st, f, tool, Operation::Cut, std::slice::from_ref(&target))?;
|
||||
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "hole")).collect();
|
||||
apply_traced(st, f, tool, Operation::Cut, std::slice::from_ref(&target))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1081,7 +1249,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
&& let Ok(b) = kernel::raise_band(&mb.body, *face, w, h, *outside)
|
||||
&& let Some(slot) = st.bodies.iter_mut().find(|x| x.name == target)
|
||||
{
|
||||
*slot = ModelBody::new(target.clone(), b, slot.feature);
|
||||
*slot = ModelBody::traced_new(target.clone(), Traced::step(b, &[&mb.traced()]), slot.feature);
|
||||
return Ok(());
|
||||
}
|
||||
// The band along the chosen edge, inside the rim face. A groove's cutter reaches past
|
||||
@@ -1100,11 +1268,11 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
};
|
||||
let delta = (h * 0.05).clamp(1e-3, 0.5);
|
||||
if *groove {
|
||||
let tool = kernel::extrude(&plane, &[band], -(h + g), delta)?;
|
||||
apply_op(st, f, tool, Operation::Cut, std::slice::from_ref(&target))
|
||||
let tool = kernel::extrude(&plane, &[band], -(h + g), delta)?.into_iter().map(|b| crate::lineage::part_tool(b, "groove")).collect();
|
||||
apply_traced(st, f, tool, Operation::Cut, std::slice::from_ref(&target))
|
||||
} else {
|
||||
let tool = kernel::extrude(&plane, &[band], -delta, h)?;
|
||||
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
let tool = kernel::extrude(&plane, &[band], -delta, h)?.into_iter().map(|b| crate::lineage::part_tool(b, "lip")).collect();
|
||||
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
}
|
||||
FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => {
|
||||
@@ -1127,7 +1295,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
let sgn = if pl.normal().dot(y) >= 0.0 { 1.0 } else { -1.0 };
|
||||
let (lo, hi) = if sgn > 0.0 { (0.0, w) } else { (-w, 0.0) };
|
||||
let tool = kernel::extrude(&pl, &[Region2 { outer: lp, holes: Vec::new() }], lo, hi)?;
|
||||
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "arm")).collect();
|
||||
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => {
|
||||
let target = body_under(st, body, *position)?;
|
||||
@@ -1176,7 +1345,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
} else {
|
||||
kernel::extrude(&base, &[region], 0.0, h + delta)?
|
||||
};
|
||||
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "rest")).collect();
|
||||
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
_ => Err(DocError::Invalid(format!("{} is not a plastic feature", f.name))),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Evaluation of the surface features: Patch, Stitch, Thicken and Trim (kernel `surfaces`).
|
||||
//! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend and Surface Extrude
|
||||
//! (kernel `surfaces`).
|
||||
|
||||
use super::*;
|
||||
use solidcraft_geom::Seg2;
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
match &f.kind {
|
||||
@@ -84,6 +86,83 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
if extent.through_all || extent.taper.is_some() || extent.to.is_some() || extent.from.is_some() {
|
||||
return Err(DocError::Invalid("not supported yet: a surface extrude through all, to or from an object, or with a taper".into()));
|
||||
}
|
||||
let d = val(vals, &extent.distance, Kind::Length)?;
|
||||
let off = extent.start_offset.as_deref().map(|e| val(vals, e, Kind::Length)).transpose()?.unwrap_or(0.0);
|
||||
let (a, b) = extent_sides(vals, extent, d, extent.direction)?;
|
||||
let mut chains: Vec<Vec<Seg2>> = Vec::new();
|
||||
if let Some(sel) = profiles {
|
||||
for r in solidcraft_sketch::merge_regions(&select_profiles(&ss, sel)?) {
|
||||
chains.extend(std::iter::once(r.outer).chain(r.holes).map(|lp| lp.segs));
|
||||
}
|
||||
}
|
||||
chains.extend(curve_chains(&ss, curves)?);
|
||||
if chains.is_empty() {
|
||||
return Err(DocError::Invalid("pick the profiles or curves to extrude".into()));
|
||||
}
|
||||
let body = kernel::extrude_sheet(&ss.plane, &chains, a + off, b + off)?;
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, body, f.id));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(DocError::Invalid("not a surface feature".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Most sketch curves one feature chains.
|
||||
const MAX_CHAIN_CURVES: usize = 1000;
|
||||
|
||||
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
|
||||
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
|
||||
/// starts a chain of its own.
|
||||
fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
|
||||
if ids.len() > MAX_CHAIN_CURVES {
|
||||
return Err(DocError::Invalid(format!("at most {MAX_CHAIN_CURVES} curves")));
|
||||
}
|
||||
let mut pieces: std::collections::VecDeque<Vec<Seg2>> = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
|
||||
Ok(ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
let reversed = |p: Vec<Seg2>| -> Vec<Seg2> { p.iter().rev().map(Seg2::reversed).collect() };
|
||||
let near = |p: Vec2, q: Vec2| p.dist(q) <= 1e-6;
|
||||
let mut chains = Vec::new();
|
||||
while let Some(mut chain) = pieces.pop_front() {
|
||||
while let (Some(head), Some(tail)) = (chain.first().map(Seg2::start), chain.last().map(Seg2::end)) {
|
||||
if near(head, tail) {
|
||||
break;
|
||||
}
|
||||
let ends = |p: &Vec<Seg2>| p.first().map(Seg2::start).zip(p.last().map(Seg2::end));
|
||||
let Some(i) = pieces.iter().position(|p| ends(p).is_some_and(|(s, e)| near(s, tail) || near(e, tail) || near(s, head) || near(e, head)))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let Some(p) = pieces.remove(i) else { break };
|
||||
let Some((s, e)) = ends(&p) else { continue };
|
||||
if near(s, tail) {
|
||||
chain.extend(p);
|
||||
} else if near(e, tail) {
|
||||
chain.extend(reversed(p));
|
||||
} else if near(e, head) {
|
||||
chain = p.into_iter().chain(chain).collect();
|
||||
} else {
|
||||
chain = reversed(p).into_iter().chain(chain).collect();
|
||||
}
|
||||
}
|
||||
chains.push(chain);
|
||||
}
|
||||
Ok(chains)
|
||||
}
|
||||
|
||||
@@ -350,6 +350,17 @@ pub fn is_literal(s: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the expression a bare number without a unit, like `20` or `-1.5`?
|
||||
pub fn is_bare_number(s: &str) -> bool {
|
||||
is_literal(s) && s.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok()
|
||||
}
|
||||
|
||||
/// The length unit of a design with these units: `mm` unless they name another length unit.
|
||||
pub fn length_unit_or_mm(units: &str) -> &str {
|
||||
let u = units.trim();
|
||||
if unit_info(u).is_some_and(|(k, _)| k == Kind::Length) { u } else { "mm" }
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
toks: Vec<Tok>,
|
||||
pos: usize,
|
||||
@@ -942,5 +953,7 @@ mod tests {
|
||||
assert_eq!(rename_reference("10 mm", "mm", "x"), "10 mm");
|
||||
assert!(is_literal("20") && is_literal("-1.5 in") && is_literal("3 deg"));
|
||||
assert!(!is_literal("d1") && !is_literal("2 * 3") && !is_literal("2 width"));
|
||||
assert!(is_bare_number(" -1.5 ") && is_bare_number("+2") && !is_bare_number("20 mm") && !is_bare_number("(2)"));
|
||||
assert_eq!((length_unit_or_mm(" in "), length_unit_or_mm("deg"), length_unit_or_mm("")), ("in", "mm", "mm"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,71 @@
|
||||
//! 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.
|
||||
//! - 17: a `lineage` face name can be a piece's own, qualified by the faces around it
|
||||
//! (`F2:top#[F2:side:l4,F4:side:l4]`), where Offset Face moved one piece of a face off the
|
||||
//! others' plane; never a numbered piece. Sheet face names can also name a rim, the sheet's
|
||||
//! thickness along a panel's straight edge (`sheet:<body>:<panel>:rim.<edge>`, the edge a base
|
||||
//! sketch line's id, `edge<k>`, or a flange's `far`, `side0` or `side1`), or a flat end of a
|
||||
//! bend (`rim.bend0`, `rim.bend1`). Version 16 designs need no transformation; the version
|
||||
//! stamp makes 0.8.0 (which reads up to 16) ask for an update instead of meeting names it
|
||||
//! can't resolve.
|
||||
//! Convert to Sheet Metal can take bend edges (`bends` in the feature: points on the sharp
|
||||
//! edges of a solid or shelled body that become bends). A conversion without `bends` is the
|
||||
//! plate conversion it always was and saves as before; 0.8.0 would drop the bend edges and
|
||||
//! refuse the body as no plate.
|
||||
//! A sketch's link to a projected edge can also keep the edge's persistent name (`edge_name`,
|
||||
//! beside `face_name`) and is then found by it, wherever the edge went. A link without it is
|
||||
//! the edge nearest its point, as it always was, and takes the name when its sketch is next
|
||||
//! edited.
|
||||
//! A fillet's `fit` can be `taper` (the radius follows the room along each edge it doesn't
|
||||
//! fit). Fillets with `exact` or `reduce` save as before; 0.8.0 would refuse the unknown fit.
|
||||
//! Feature-history folders are also first saved in 17 (`timeline_groups`: stable folder id,
|
||||
//! name, optional parent folder and feature ids). Version 16 designs have no saved timeline
|
||||
//! folders and open with the empty default.
|
||||
//! A fillet's `type` can be `spline` (the radius along each edge a B-spline: `start`, `knots`,
|
||||
//! and the control radii after `radius` in `radii`). Other fillets save as before; 0.8.0 would
|
||||
//! refuse the unknown type.
|
||||
//! Under the same version, a sheet metal rule's corner relief shape can also be `tear` (no
|
||||
//! relief: each bend is cut straight from the bends' corner to where its flange's end
|
||||
//! starts); a rule that says `notch`, `round` or `rectangular` builds what it did.
|
||||
//! A sketch's view options can also say `hide_construction` (the Sketch Palette's
|
||||
//! Construction Geometries); a sketch that shows its construction geometry saves as before.
|
||||
//! A `SurfaceExtrude` feature (Surface Extrude: `sketch`, optional `profiles`, `curves` and
|
||||
//! `extent`, walls only) is first saved in 17; 0.8.0 would refuse the unknown feature.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 14;
|
||||
pub const FORMAT: u32 = 17;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solidcraft/{n}")
|
||||
@@ -69,7 +127,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; 16] = [
|
||||
|_| Ok(()),
|
||||
v2_to_v3,
|
||||
v3_to_v4,
|
||||
@@ -83,6 +141,9 @@ const JSON_STEPS: [JsonStep; 13] = [
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
v13_to_v14,
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
];
|
||||
|
||||
/// Stored face and edge names were worked out from geometry before format 14; they get the
|
||||
@@ -316,9 +377,43 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
if d.features.len() > crate::document::MAX_FEATURES
|
||||
|| d.params.len() > crate::document::MAX_PARAMS
|
||||
|| d.components.len() > crate::document::MAX_FEATURES
|
||||
|| d.timeline_groups.len() > crate::document::MAX_FEATURES
|
||||
|| d.timeline_groups.iter().any(|group| group.features.len() > crate::document::MAX_FEATURES)
|
||||
{
|
||||
return Err(DocError::Invalid("document too large".into()));
|
||||
}
|
||||
let feature_ids: std::collections::BTreeSet<_> = d.features.iter().map(|feature| feature.id).collect();
|
||||
let mut group_ids = std::collections::BTreeSet::new();
|
||||
let mut grouped_features = std::collections::BTreeSet::new();
|
||||
for group in &d.timeline_groups {
|
||||
if group.name.trim().is_empty() || group.name.len() > 128 || !group_ids.insert(group.id) {
|
||||
return Err(DocError::Invalid("damaged design: invalid timeline folder".into()));
|
||||
}
|
||||
if group.parent.is_some_and(|parent| parent == group.id) {
|
||||
return Err(DocError::Invalid("damaged design: timeline folder cannot contain itself".into()));
|
||||
}
|
||||
if group.features.iter().any(|feature| !feature_ids.contains(feature) || !grouped_features.insert(*feature)) {
|
||||
return Err(DocError::Invalid("damaged design: invalid feature in a timeline folder".into()));
|
||||
}
|
||||
}
|
||||
for group in &d.timeline_groups {
|
||||
if let Some(parent) = group.parent {
|
||||
if !group_ids.contains(&parent) {
|
||||
return Err(DocError::Invalid("damaged design: timeline folder has an unknown parent".into()));
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut current = Some(parent);
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
return Err(DocError::Invalid("damaged design: cyclic timeline folders".into()));
|
||||
}
|
||||
current = d.timeline_groups.iter().find(|candidate| candidate.id == id).and_then(|candidate| candidate.parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(id) = crate::non_contiguous_timeline_group(&d.features, &d.timeline_groups) {
|
||||
return Err(DocError::Invalid(format!("damaged design: timeline folder {id} is not contiguous")));
|
||||
}
|
||||
for o in &d.occurrences {
|
||||
if !rigid(&o.transform) {
|
||||
return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -160,6 +160,27 @@ impl Traced {
|
||||
let lineage = lineages.and_then(|l| Lineage::step(&result, &l));
|
||||
Traced { body: result, lineage }
|
||||
}
|
||||
|
||||
/// `result`, made by one kernel operation from this body alone. Where that operation's
|
||||
/// history can't be composed with this body's (it blends an edge the same feature made: a
|
||||
/// boss's root fillet), this body is a stage of the feature, its faces and edges named as a
|
||||
/// whole first.
|
||||
pub fn then(&self, result: Body) -> Traced {
|
||||
let step = Traced::step(result.clone(), &[self]);
|
||||
if step.lineage.is_some() {
|
||||
return step;
|
||||
}
|
||||
let lineage = self.lineage.as_ref().and_then(|l| {
|
||||
let history = result.history()?.clone();
|
||||
if history.operands != 1 {
|
||||
return None;
|
||||
}
|
||||
let body = crate::ModelBody::new(String::new(), self.body.clone(), 0);
|
||||
let stage = Arc::new(Operand::Stage(Box::new(Stage { body, lineage: l.clone(), names: OnceLock::new() })));
|
||||
Some(Lineage { history, operands: vec![stage] })
|
||||
});
|
||||
Traced { body: result, lineage }
|
||||
}
|
||||
}
|
||||
|
||||
/// How the bodies a feature builds from a profile name their faces: what each profile segment's
|
||||
@@ -186,7 +207,7 @@ impl<'a> ProfileRoles<'a> {
|
||||
let segments: Vec<&solidcraft_geom::Seg2> =
|
||||
self.region.map(|r| std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).collect()).unwrap_or_default();
|
||||
let curves = self.sketch.map(crate::config::ExactSketchCurves::new);
|
||||
let curve = |segment: usize| -> Option<String> { curves.as_ref()?.find(segments.get(segment)?).map(|id| format!("side:{id}")) };
|
||||
let curve = |segment: usize| -> Option<String> { curves.as_ref()?.find_along(segments.get(segment)?).map(|id| format!("side:{id}")) };
|
||||
let roles = history
|
||||
.faces
|
||||
.iter()
|
||||
@@ -228,6 +249,64 @@ impl<'a> ProfileRoles<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a loft names its faces: each side by the sketch curves of the sections it runs between
|
||||
/// (`side:<curve>` for the first section's, `side:s<k>.<curve>` for section `k`'s), named after
|
||||
/// the section with the most segments (the one each side spans exactly one segment of) and
|
||||
/// answering to the others; its caps `start` and `end`.
|
||||
pub(crate) struct SectionRoles<'a> {
|
||||
/// Each section's sketch and the loop lofted through it, in the order given.
|
||||
pub sections: Vec<(&'a SolvedSketch, &'a solidcraft_geom::Loop2)>,
|
||||
}
|
||||
|
||||
impl SectionRoles<'_> {
|
||||
/// The loft `body`, its faces named by their roles where its history gives them.
|
||||
pub fn tool(&self, body: Body) -> Traced {
|
||||
let roles = body.history().filter(|h| h.operands == 0).map(|h| self.of(h));
|
||||
Traced::made(body, roles)
|
||||
}
|
||||
|
||||
fn of(&self, history: &History) -> Vec<Vec<String>> {
|
||||
// Each profile segment (numbered through the sections in order): its section and role.
|
||||
let mut segments: Vec<(usize, Option<String>)> = Vec::new();
|
||||
for (k, (sketch, lp)) in self.sections.iter().enumerate() {
|
||||
let curves = crate::config::ExactSketchCurves::new(sketch);
|
||||
for seg in &lp.segs {
|
||||
let role = curves.find_along(seg).map(|id| if k == 0 { format!("side:{id}") } else { format!("side:s{k}.{id}") });
|
||||
segments.push((k, role));
|
||||
}
|
||||
}
|
||||
let lead = self.sections.iter().enumerate().max_by_key(|(k, (_, lp))| (lp.segs.len(), std::cmp::Reverse(*k))).map_or(0, |(k, _)| k);
|
||||
history
|
||||
.faces
|
||||
.iter()
|
||||
.map(|face| {
|
||||
let mut roles: Vec<(bool, usize, String)> = Vec::new();
|
||||
for origin in &face.origins {
|
||||
let (k, role) = match *origin {
|
||||
FaceOrigin::Cap { end } => (0, Some(if end { "end" } else { "start" }.to_string())),
|
||||
FaceOrigin::Profile { segment } => match segments.get(segment) {
|
||||
Some((k, role)) => (*k, role.clone()),
|
||||
None => (0, None),
|
||||
},
|
||||
_ => (0, None),
|
||||
};
|
||||
// One unknown origin leaves the face unknown: never a partial guess.
|
||||
let Some(role) = role else { return Vec::new() };
|
||||
roles.push((k != lead, k, role));
|
||||
}
|
||||
roles.sort();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (.., role) in roles {
|
||||
if !out.contains(&role) {
|
||||
out.push(role);
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A box primitive, its faces named by the side they face (`+x`, `-z`, …), as its construction
|
||||
/// records them.
|
||||
pub(crate) fn box_tool(body: Body) -> Traced {
|
||||
@@ -260,6 +339,23 @@ pub(crate) fn cylinder_tool(body: Body) -> Traced {
|
||||
Traced::made(body, roles)
|
||||
}
|
||||
|
||||
/// A tool a feature builds without a sketch (a boss's post, a rib, a lip's band), its faces named
|
||||
/// by the part it makes: `<part>:top` (its end cap), `<part>:base` (its start cap) and
|
||||
/// `<part>:side:<k>` (swept from segment `k` of its profile), as its construction records them.
|
||||
pub(crate) fn part_tool(body: Body, part: &str) -> Traced {
|
||||
let roles = body.history().filter(|h| h.operands == 0).map(|h| {
|
||||
h.faces
|
||||
.iter()
|
||||
.map(|f| match f.origins.as_slice() {
|
||||
[FaceOrigin::Cap { end }] => vec![format!("{part}:{}", if *end { "top" } else { "base" })],
|
||||
[FaceOrigin::Profile { segment }] => vec![format!("{part}:side:{segment}")],
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Traced::made(body, roles)
|
||||
}
|
||||
|
||||
/// Faces named by their place in a body built as a whole (an imported part's faces in the
|
||||
/// file's order): `face:<k>`.
|
||||
pub(crate) fn numbered_tool(body: Body) -> Traced {
|
||||
|
||||
@@ -30,6 +30,8 @@ pub fn legacy_name(name: &str) -> Option<&str> {
|
||||
/// Where a stored edge name was found.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EdgeHit {
|
||||
/// The edge (its index in the body's mesh).
|
||||
pub edge: usize,
|
||||
/// A point on the edge: the reference's own point when it lies on it, else the edge's middle.
|
||||
pub point: Vec3,
|
||||
/// The named edge is not one edge any more: the piece nearest the point was taken.
|
||||
@@ -62,7 +64,7 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
|
||||
let exact = |i: usize| -> Result<EdgeHit, String> {
|
||||
let on = edge_near(b, &m, i, near);
|
||||
mid(i)
|
||||
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
|
||||
.map(|p| EdgeHit { edge: i, point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
|
||||
.ok_or_else(|| "edge".into())
|
||||
};
|
||||
if let Some(i) = names.iter().position(|n| n == name) {
|
||||
@@ -84,7 +86,7 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
|
||||
.iter()
|
||||
.filter_map(|i| mid(*i).map(|p| (p, *i)))
|
||||
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
|
||||
.map(|(p, i)| EdgeHit { point: p, split: true, verified: verified(i), on_point: false, legacy: false })
|
||||
.map(|(p, i)| EdgeHit { edge: i, point: p, split: true, verified: verified(i), on_point: false, legacy: false })
|
||||
.ok_or_else(|| format!("edge `{name}` no longer exists"))
|
||||
}
|
||||
|
||||
@@ -104,7 +106,8 @@ fn nearest_on_face(m: &Mesh, face: usize, p: Vec3) -> Option<(f64, Vec3)> {
|
||||
let Some([a, b, c]) = m.tri(t) else { continue };
|
||||
let q = closest(p, a, b, c);
|
||||
let d = q.dist(p);
|
||||
if best.is_none_or(|x| d < x.0) {
|
||||
// A non-finite candidate must never become the best: nothing compares less than NaN.
|
||||
if d.is_finite() && best.is_none_or(|x| d < x.0) {
|
||||
best = Some((d, q));
|
||||
}
|
||||
}
|
||||
@@ -136,15 +139,15 @@ fn legacy_locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit,
|
||||
if let Some(i) = names.iter().position(|n| n == name) {
|
||||
let on = edge_near(b, &m, i, near);
|
||||
return edge_mid(&m, i)
|
||||
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: false, on_point: on, legacy: true })
|
||||
.map(|p| EdgeHit { edge: i, point: if on { near } else { p }, split: false, verified: false, on_point: on, legacy: true })
|
||||
.ok_or(());
|
||||
}
|
||||
let stem = strip_edge_piece(name);
|
||||
(0..names.len())
|
||||
.filter(|i| names.get(*i).is_some_and(|n| strip_edge_piece(n) == stem))
|
||||
.filter_map(|i| edge_mid(&m, i))
|
||||
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
|
||||
.map(|p| EdgeHit { point: p, split: true, verified: false, on_point: false, legacy: true })
|
||||
.filter_map(|i| edge_mid(&m, i).map(|p| (p, i)))
|
||||
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
|
||||
.map(|(p, i)| EdgeHit { edge: i, point: p, split: true, verified: false, on_point: false, legacy: true })
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
@@ -211,6 +214,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> {
|
||||
@@ -288,7 +320,7 @@ fn legacy_locate<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name:
|
||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||
let q = closest(p, a, bb, c);
|
||||
let d = q.dist(p) - if exact { 1e-9 } else { 0.0 };
|
||||
if best.is_none_or(|x| d < x.0) {
|
||||
if d.is_finite() && best.is_none_or(|x| d < x.0) {
|
||||
best = Some((d, bi, *f as usize, q, !exact, tol));
|
||||
}
|
||||
}
|
||||
|
||||
+46
-12
@@ -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.
|
||||
@@ -112,6 +114,8 @@ fn samples(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The point of triangle `abc` nearest `p`. A zero-length edge (a cone's tip repeats its apex
|
||||
/// vertex) has no interior to project onto, so its branch is skipped rather than dividing 0 by 0.
|
||||
pub(crate) fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
let (ab, ac, ap) = (b - a, c - a, p - a);
|
||||
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
|
||||
@@ -124,7 +128,7 @@ pub(crate) fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
return b;
|
||||
}
|
||||
let vc = d1 * d4 - d3 * d2;
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 && d1 - d3 > 0.0 {
|
||||
return a + ab * (d1 / (d1 - d3));
|
||||
}
|
||||
let cp = p - c;
|
||||
@@ -133,11 +137,11 @@ pub(crate) fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
return c;
|
||||
}
|
||||
let vb = d5 * d2 - d1 * d6;
|
||||
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
|
||||
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 && d2 - d6 > 0.0 {
|
||||
return a + ac * (d2 / (d2 - d6));
|
||||
}
|
||||
let va = d3 * d6 - d5 * d4;
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 && (d4 - d3) + (d5 - d6) > 0.0 {
|
||||
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
|
||||
}
|
||||
let den = va + vb + vc;
|
||||
@@ -334,6 +338,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 +408,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 +476,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;
|
||||
}
|
||||
@@ -735,6 +755,20 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A cone's tip repeats its apex vertex, so a triangle can have a zero-length edge. The nearest
|
||||
/// point on it must still be a real point (it used to be NaN, which broke picking the tip).
|
||||
#[test]
|
||||
fn closest_point_on_a_degenerate_triangle_is_finite() {
|
||||
let (a, c) = (Vec3::new(0., 0., 0.), Vec3::new(2., 0., 0.));
|
||||
for (b, c) in [(a, c), (c, c), (a, a)] {
|
||||
for p in [Vec3::new(1., 1., 0.), Vec3::new(-1., 0.5, 0.), Vec3::new(3., -2., 1.), a] {
|
||||
let q = closest(p, a, b, c);
|
||||
assert!(q.x.is_finite() && q.y.is_finite() && q.z.is_finite(), "{a:?} {b:?} {c:?} {p:?} -> {q:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(closest(Vec3::new(1., 1., 0.), a, a, c), Vec3::new(1., 0., 0.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_face_votes_collectively_for_pieces_of_one_source() {
|
||||
let body = ModelBody::new("Input".into(), solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(4., 1., 1.)).unwrap(), 1);
|
||||
|
||||
@@ -54,7 +54,7 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
},
|
||||
FeatureKind::Extrude { extent, .. } => {
|
||||
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
|
||||
v.push(("Distance", &mut extent.distance, L));
|
||||
if let Some(d) = &mut extent.distance2 {
|
||||
v.push(("Distance 2", d, L));
|
||||
@@ -83,6 +83,11 @@ impl FeatureKind {
|
||||
v.push(("End radius", radius2, L));
|
||||
v.extend(mid.iter_mut().map(|(_, r)| ("Mid radius", r, L)));
|
||||
}
|
||||
crate::FilletStyle::Spline { radii, .. } => {
|
||||
v.push(("Start radius", radius, L));
|
||||
let n = radii.len();
|
||||
v.extend(radii.iter_mut().enumerate().map(|(i, r)| (if i + 1 == n { "End radius" } else { "Control radius" }, r, L)));
|
||||
}
|
||||
},
|
||||
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
|
||||
v.push(("Distance", distance, L));
|
||||
@@ -223,12 +228,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));
|
||||
@@ -457,7 +471,7 @@ impl Document {
|
||||
/// in (feature inputs are stored in mm otherwise). Anything else is returned unchanged.
|
||||
pub fn with_design_unit(&self, e: &str, kind: Kind) -> String {
|
||||
let u = self.units.trim();
|
||||
let bare = expr::is_literal(e) && e.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok();
|
||||
let bare = expr::is_bare_number(e);
|
||||
if kind == Kind::Length && bare && !u.is_empty() && u != "mm" && expr::unit_info(u).is_some_and(|(k, _)| k == Kind::Length) {
|
||||
format!("{} {u}", e.trim())
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 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)]
|
||||
pub struct Progress {
|
||||
cancelled: AtomicBool,
|
||||
done: AtomicUsize,
|
||||
total: AtomicUsize,
|
||||
/// The feature the evaluation waits at until cancelled ([`Progress::hold_at`]).
|
||||
hold: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Default for Progress {
|
||||
fn default() -> Self {
|
||||
Progress { cancelled: AtomicBool::new(false), done: AtomicUsize::new(0), total: AtomicUsize::new(0), hold: AtomicUsize::new(usize::MAX) }
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
/// Make the watched evaluation wait before feature `index` until it is cancelled: tests
|
||||
/// use it to act while a load is certainly still running, whatever the machine's speed.
|
||||
pub fn hold_at(&self, index: usize) {
|
||||
self.hold.store(index, 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);
|
||||
while index == cur.progress.hold.load(Ordering::Relaxed) && !cur.progress.is_cancelled() {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
+101
-20
@@ -1,8 +1,9 @@
|
||||
//! Resolving sketch links against the model: projecting edges, faces, body silhouettes, other
|
||||
//! sketches and the origin onto a sketch plane, and intersecting bodies with it.
|
||||
//!
|
||||
//! Model geometry is found again on every recompute from the reference point stored in the
|
||||
//! link (like fillet edges). Edge polylines from the tessellation lie exactly on their curves,
|
||||
//! Model geometry is found again on every recompute: a face or an edge by the persistent name
|
||||
//! its link keeps, anything else (and a link saved without a name) from the reference point
|
||||
//! stored in the link. Edge polylines from the tessellation lie exactly on their curves,
|
||||
//! so lines and circular arcs are recovered exactly by fitting; anything else (an ellipse from
|
||||
//! a tilted circle, a spline edge) becomes a chain of lines.
|
||||
|
||||
@@ -35,6 +36,9 @@ pub struct Resolved {
|
||||
/// Exact face selected during this resolution, before its closest point is
|
||||
/// projected onto an edge shared with another face. Computed metadata only.
|
||||
pub face_index: Option<usize>,
|
||||
/// The edge an `Edge` reference resolved to (its index in the body's mesh), whose
|
||||
/// persistent name the link keeps ([`resolved_edge_name`]). Computed metadata only.
|
||||
pub edge_index: Option<usize>,
|
||||
}
|
||||
|
||||
/// Resolutions of body-based references, keyed by the reference, the sketch plane and the
|
||||
@@ -98,13 +102,9 @@ fn resolve_uncached(
|
||||
let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists"));
|
||||
let mut moved = false;
|
||||
let mut face_index = None;
|
||||
let mut edge_index = None;
|
||||
let mut source = src.clone();
|
||||
let mut wires: Vec<Vec<Vec3>> = Vec::new();
|
||||
// Included curves that leave the sketch plane stay 3D.
|
||||
let off_plane = |pts: &[Vec3]| {
|
||||
let size = pts.iter().fold(1.0_f64, |m, p| m.max(p.len()));
|
||||
pts.iter().any(|p| plane.height(*p).abs() > size * 1e-7)
|
||||
};
|
||||
let geom = match src {
|
||||
LinkSource::Edge { body, at } => {
|
||||
let (bi, mesh) = body_mesh(st, body, *at)?;
|
||||
@@ -116,15 +116,8 @@ fn resolve_uncached(
|
||||
moved = d > (size * 2e-3).max(1e-3);
|
||||
let name = st.bodies.get(bi).map(|b| b.name.clone()).unwrap_or_else(|| body.clone());
|
||||
source = LinkSource::Edge { body: name, at: q };
|
||||
let pts = mesh.edges.get(e).cloned().unwrap_or_default();
|
||||
match kind {
|
||||
LinkKind::Intersect => section_of_polyline(plane, &pts),
|
||||
LinkKind::Include if off_plane(&pts) => {
|
||||
wires.push(pts);
|
||||
Vec::new()
|
||||
}
|
||||
_ => fit_world(plane, &pts),
|
||||
}
|
||||
edge_index = Some(e);
|
||||
edge_geom(plane, kind, mesh.edges.get(e).cloned().unwrap_or_default(), &mut wires)
|
||||
}
|
||||
LinkSource::Face { body, at } => {
|
||||
let (bi, mesh) = body_mesh(st, body, *at)?;
|
||||
@@ -188,7 +181,7 @@ fn resolve_uncached(
|
||||
}
|
||||
LinkKind::Include => {
|
||||
let pts: Vec<Vec3> = ss.sketch.polyline(ci).iter().map(|p| ss.plane.to_world(*p)).collect();
|
||||
if off_plane(&pts) {
|
||||
if off_plane(plane, &pts) {
|
||||
wires.push(pts);
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -259,7 +252,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() } };
|
||||
@@ -270,7 +266,34 @@ fn resolve_uncached(
|
||||
if geom.is_empty() && wires.is_empty() {
|
||||
return Err(DocError::Invalid(format!("the {} projects to nothing on this sketch plane", src.describe())));
|
||||
}
|
||||
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index })
|
||||
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index, edge_index })
|
||||
}
|
||||
|
||||
/// Whether a curve (world points) leaves the sketch plane: included, it then stays 3D.
|
||||
fn off_plane(plane: &Plane, pts: &[Vec3]) -> bool {
|
||||
let size = pts.iter().fold(1.0_f64, |m, p| m.max(p.len()));
|
||||
pts.iter().any(|p| plane.height(*p).abs() > size * 1e-7)
|
||||
}
|
||||
|
||||
/// What a body edge (its points) gives a sketch: its section with the sketch plane, itself as a
|
||||
/// 3D curve (into `wires`) when it is included and leaves the plane, else its projection.
|
||||
fn edge_geom(plane: &Plane, kind: LinkKind, pts: Vec<Vec3>, wires: &mut Vec<Vec<Vec3>>) -> Vec<LinkGeom> {
|
||||
match kind {
|
||||
LinkKind::Intersect => section_of_polyline(plane, &pts),
|
||||
LinkKind::Include if off_plane(plane, &pts) => {
|
||||
wires.push(pts);
|
||||
Vec::new()
|
||||
}
|
||||
_ => fit_world(plane, &pts),
|
||||
}
|
||||
}
|
||||
|
||||
/// The persistent name of the edge an `Edge` reference resolved to, for its link to keep and
|
||||
/// be found by from then on. None on a mesh body, whose edges have no names.
|
||||
pub fn resolved_edge_name(st: &ModelState, r: &Resolved) -> Option<String> {
|
||||
let (LinkSource::Edge { body, .. }, Some(index)) = (&r.source, r.edge_index) else { return None };
|
||||
let body = st.body(body).filter(|body| !body.body.is_mesh())?;
|
||||
crate::naming::edge_names(body).get(index).filter(|name| !name.is_empty()).cloned()
|
||||
}
|
||||
|
||||
/// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt
|
||||
@@ -279,6 +302,36 @@ fn lost(reference: &str) -> DocError {
|
||||
DocError::Unknown(format!("{reference} no longer exists"))
|
||||
}
|
||||
|
||||
/// The edge a link's stored name finds, and the body it is on: the link's body, or, when no
|
||||
/// body has that name any more (it was joined into another), the body on which the name finds
|
||||
/// the edge nearest the link's point.
|
||||
fn locate_named_edge(st: &ModelState, body: &str, name: &str, at: Vec3) -> Option<(String, crate::naming::EdgeHit)> {
|
||||
let hit = |b: &crate::ModelBody| crate::naming::locate_edge(b, name, at).ok().map(|hit| (b.name.clone(), hit));
|
||||
match st.body(body) {
|
||||
Some(b) => hit(b),
|
||||
None => st.bodies.iter().filter(|b| !b.body.is_mesh()).filter_map(hit).min_by(|a, b| a.1.point.dist(at).total_cmp(&b.1.point.dist(at))),
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge found by its persistent name: edge `index` of `body`, with the link's point `near`
|
||||
/// moved onto it.
|
||||
fn resolve_named_edge(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, near: Vec3) -> Result<Resolved> {
|
||||
let target = st.body(body).ok_or_else(|| lost("named projection body"))?;
|
||||
let pts = target.mesh().edges.get(index).cloned().unwrap_or_default();
|
||||
let at = pts
|
||||
.windows(2)
|
||||
.filter_map(|w| Some(closest_on_segment(near, *w.first()?, *w.get(1)?)))
|
||||
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
|
||||
.ok_or_else(|| lost("named projection edge"))?;
|
||||
let mut wires = Vec::new();
|
||||
let geom = edge_geom(plane, kind, pts, &mut wires);
|
||||
if geom.is_empty() && wires.is_empty() {
|
||||
return Err(DocError::Invalid("named edge projects to nothing on this sketch plane".into()));
|
||||
}
|
||||
let source = LinkSource::Edge { body: body.into(), at };
|
||||
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved: false, face_index: None, edge_index: Some(index) })
|
||||
}
|
||||
|
||||
fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, at: Vec3) -> Result<Resolved> {
|
||||
let target = st.body(body).ok_or_else(|| lost("named projection body"))?;
|
||||
let mesh = target.mesh();
|
||||
@@ -302,7 +355,7 @@ fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
|
||||
if geom.is_empty() {
|
||||
return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into()));
|
||||
}
|
||||
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index })
|
||||
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index, edge_index: None })
|
||||
}
|
||||
|
||||
fn projection_face_edges(body: &crate::ModelBody, mesh: &Mesh, index: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
@@ -321,6 +374,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 { .. }
|
||||
)
|
||||
@@ -388,6 +442,7 @@ pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body
|
||||
source: LinkSource::Face { body: body.name.clone(), at: point },
|
||||
moved: false,
|
||||
face_index: if indices.len() == 1 { indices.first().copied() } else { None },
|
||||
edge_index: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -515,6 +570,22 @@ fn refresh_links_in(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// An edge the link keeps by name is that edge of its body, wherever it went. When no
|
||||
// edge has the name any more the link is lost: no other edge is taken for it.
|
||||
let mut named_edge = None;
|
||||
if let (Some(name), LinkSource::Edge { body, at }) = (&l.edge_name, &source) {
|
||||
match locate_named_edge(st, body, name, *at) {
|
||||
Some((body, hit)) => {
|
||||
warn.extend(hit.note(name).map(|note| format!("projected {note}")));
|
||||
named_edge = Some((body, hit.edge, *at));
|
||||
}
|
||||
None => {
|
||||
sk.set_link_lost(&l.id, true);
|
||||
warn.push(format!("projected reference lost: edge `{name}` no longer exists; last geometry retained"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let resolved = if l.auto_face {
|
||||
anchor
|
||||
.filter(|anchor| {
|
||||
@@ -524,11 +595,21 @@ fn refresh_links_in(
|
||||
.and_then(|anchor| resolve_anchored_face(doc, st, plane, l.kind, anchor))
|
||||
} else if let Some((body, index, at)) = named_face {
|
||||
resolve_named_face(st, plane, l.kind, &body, index, at)
|
||||
} else if let Some((body, index, at)) = named_edge {
|
||||
resolve_named_edge(st, plane, l.kind, &body, index, at)
|
||||
} else {
|
||||
resolve(doc, vals, st, plane, l.kind, &source)
|
||||
};
|
||||
match resolved {
|
||||
Ok(r) => {
|
||||
// An edge link saved without a name takes the name of the edge it was found
|
||||
// on. A sketch that is edited stores it; evaluation leaves the design as it is.
|
||||
if l.edge_name.is_none()
|
||||
&& let Some(name) = resolved_edge_name(st, &r)
|
||||
&& let Some(link) = sk.links.iter_mut().find(|link| link.id == l.id)
|
||||
{
|
||||
link.edge_name = Some(name);
|
||||
}
|
||||
sk.set_link_source(&l.id, r.source);
|
||||
let _ = sk.set_link_wires(&l.id, &r.wires);
|
||||
match sk.update_link(&l.id, &r.geom) {
|
||||
|
||||
+3556
-120
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
//! Convert to Sheet Metal with bend edges: the faces of a solid, or the walls of a shelled one,
|
||||
//! become one sheet.
|
||||
//!
|
||||
//! The planar faces reached from the fixed face across the bend edges are the sheet's panels,
|
||||
//! all on one skin of it (the one the fixed face is on). A body with a parallel face the same
|
||||
//! small distance behind the fixed face and behind the faces at every bend is a shell: it keeps
|
||||
//! its walls and their thickness. Any other body's faces become the outside of the sheet, with
|
||||
//! the rule's thickness taken into the body. A bend edge stays its bend's virtual sharp: the
|
||||
//! panels on either side are set back from it by (R + T)·tan(θ/2) where the skin is the bend's
|
||||
//! outside and R·tan(θ/2) where it is the inside, so the body keeps its envelope. An edge between
|
||||
//! two panels that is no bend is cut open: each panel ends square to the sheet, half the rule's
|
||||
//! gap from the plane halfway between the two.
|
||||
//!
|
||||
//! The result is the model every sheet metal feature builds ([`SheetBody`]): the fixed face is
|
||||
//! the base panel and every other panel a flange on its parent's edge, its outline taken through
|
||||
//! the inverse of its fold. Unfold, the flat pattern, the bend table and cuts then work as on a
|
||||
//! sheet made from flanges.
|
||||
//!
|
||||
//! Not supported yet: curved faces (rolled sheet, a filleted edge as a bend), faces with curved
|
||||
//! edges or holes, loops of bends, two faces that meet along more than one edge.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Vec2, Vec3};
|
||||
use solidcraft_kernel::{Body, FaceSurface, face_surface};
|
||||
|
||||
use super::{RuleValues, SheetBody, SheetFlange, base_flange, polys_cross, signed_area, snap};
|
||||
use crate::{DocError, ModelBody, Result, apply_point, mat_inverse};
|
||||
|
||||
const MAX_BENDS: usize = 1000;
|
||||
|
||||
fn err<T>(msg: impl Into<String>) -> Result<T> {
|
||||
Err(DocError::Invalid(msg.into()))
|
||||
}
|
||||
|
||||
fn at(p: Vec3) -> String {
|
||||
format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z)
|
||||
}
|
||||
|
||||
/// A planar face of the body: its outward normal and its outline, counter-clockwise about the
|
||||
/// normal. Edge `i` runs from corner `i` to the next one, along face `across[i]`.
|
||||
struct Face {
|
||||
n: Vec3,
|
||||
pts: Vec<Vec3>,
|
||||
across: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
/// A face of the sheet: the panel it hangs off (none for the fixed face), and the bend between
|
||||
/// the two: its angle (positive toward the material, as a flange's) and its setback on this skin.
|
||||
struct Panel {
|
||||
face: Face,
|
||||
parent: Option<usize>,
|
||||
angle: f64,
|
||||
setback: f64,
|
||||
}
|
||||
|
||||
/// Area of a loop in space, positive when it runs counter-clockwise about `n`.
|
||||
fn area_about(pts: &[Vec3], n: Vec3) -> f64 {
|
||||
let Some(&o) = pts.first() else { return 0.0 };
|
||||
pts.windows(2).map(|w| (w[0] - o).cross(w[1] - o).dot(n)).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
/// Turn a loop's per-edge values round with its corners (edge `i` starts at corner `i`).
|
||||
fn turn<T>(per_edge: &mut [T]) {
|
||||
per_edge.reverse();
|
||||
if !per_edge.is_empty() {
|
||||
per_edge.rotate_left(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Face `f` of the body as a panel outline. Only a planar face bounded by one loop of straight
|
||||
/// edges can be one.
|
||||
fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
|
||||
let Some(FaceSurface::Plane { normal: n, .. }) = face_surface(b, f) else {
|
||||
return err("not supported yet: curved faces in a converted sheet (only planar faces convert)");
|
||||
};
|
||||
// Its edges, each with the face on its other side.
|
||||
let mut edges = Vec::new();
|
||||
for (line, faces) in mesh.edges.iter().zip(&mesh.edge_faces) {
|
||||
if !faces.iter().any(|g| *g as usize == f) {
|
||||
continue;
|
||||
}
|
||||
let (Some(&p), Some(&q)) = (line.first(), line.last()) else { continue };
|
||||
if p.dist(q) <= tol || line.iter().any(|x| x.dist_to_segment(p, q) > tol) {
|
||||
return err("not supported yet: faces with curved edges in a converted sheet");
|
||||
}
|
||||
edges.push((p, q, faces.iter().map(|g| *g as usize).find(|g| *g != f)));
|
||||
}
|
||||
// Chained head to tail from the first one, they close one loop.
|
||||
let Some((start, mut end, across)) = edges.pop() else { return err("a face of the body has no edges") };
|
||||
let (mut pts, mut tags) = (vec![start], vec![across]);
|
||||
while end.dist(start) > tol {
|
||||
let Some(k) = edges.iter().position(|e| e.0.dist(end) <= tol || e.1.dist(end) <= tol) else {
|
||||
return err("the outline of a face of the body does not close");
|
||||
};
|
||||
let (p, q, across) = edges.swap_remove(k);
|
||||
pts.push(end);
|
||||
tags.push(across);
|
||||
end = if p.dist(end) <= tol { q } else { p };
|
||||
}
|
||||
if !edges.is_empty() {
|
||||
return err("not supported yet: faces with holes in a converted sheet (cut them after converting)");
|
||||
}
|
||||
// A straight run along one neighbour is one edge, however the body's edges divide it (a
|
||||
// shell's outer corners are divided at the height of its floor).
|
||||
let m = pts.len();
|
||||
let corner = |i: usize| -> bool {
|
||||
let before = (i + m - 1) % m;
|
||||
let (Some(&a), Some(&p), Some(&b)) = (pts.get(before), pts.get(i), pts.get((i + 1) % m)) else { return true };
|
||||
tags.get(before) != tags.get(i) || p.dist_to_segment(a, b) > tol
|
||||
};
|
||||
let keep: Vec<bool> = (0..m).map(corner).collect();
|
||||
let (mut pts, mut tags): (Vec<Vec3>, Vec<Option<usize>>) = pts.into_iter().zip(tags).zip(keep).filter(|(_, kept)| *kept).map(|(x, _)| x).unzip();
|
||||
if area_about(&pts, n) < 0.0 {
|
||||
pts.reverse();
|
||||
turn(&mut tags);
|
||||
}
|
||||
Ok(Face { n, pts, across: tags })
|
||||
}
|
||||
|
||||
/// The face straight through the material behind planar face `f`, when it is parallel to it,
|
||||
/// and how far behind it lies.
|
||||
fn behind(b: &Body, mesh: &Mesh, f: usize) -> Option<(usize, f64)> {
|
||||
let Some(FaceSurface::Plane { normal: n, point: o }) = face_surface(b, f) else { return None };
|
||||
// From the middle of the face's largest triangle.
|
||||
let area = |t: &[Vec3; 3]| (t[1] - t[0]).cross(t[2] - t[0]).len();
|
||||
let tri = mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).filter_map(|(t, _)| mesh.tri(t));
|
||||
let [p, q, r] = tri.max_by(|x, y| area(x).total_cmp(&area(y)))?;
|
||||
let (_, hit) = mesh.raycast((p + q + r) * (1.0 / 3.0), n * -1.0)?;
|
||||
let g = *mesh.tri_face.get(hit)? as usize;
|
||||
let Some(FaceSurface::Plane { normal, point }) = face_surface(b, g) else { return None };
|
||||
(normal.dot(n) < -1.0 + 1e-9).then_some((g, (o - point).dot(n)))
|
||||
}
|
||||
|
||||
/// How far a panel's edge along a rip moves in, so that the panel, cut square to the sheet,
|
||||
/// stays half the gap from the plane halfway between it and the panel `y` across the rip.
|
||||
/// `e` is the edge's direction round panel `x`.
|
||||
fn rip_cut(x: &Face, y: &Face, e: Vec3, t: f64, gap: f64) -> Result<f64> {
|
||||
// Into each panel from the edge, square to the edge.
|
||||
let (wx, wy) = (x.n.cross(e), e.cross(y.n));
|
||||
let mid = (wx - wy).normalized().filter(|m| wx.dot(*m) > 1e-9);
|
||||
let Some(mid) = mid else { return err("two faces of the sheet lie on each other along the edge between them") };
|
||||
// The sheet's other face lies `t` behind the panel: nearer that plane, or further from it.
|
||||
let lean = -x.n.dot(mid) * t;
|
||||
Ok((gap / 2.0 - lean.min(0.0)) / wx.dot(mid))
|
||||
}
|
||||
|
||||
/// A counter-clockwise outline with each edge moved in by its own amount. Moved edges meet
|
||||
/// where their lines cross; two edges in one line that move by different amounts are joined by
|
||||
/// a square step. Returns the corners and, per edge, the edge it was (none for a step).
|
||||
fn inset(pts: &[Vec2], by: &[f64]) -> Result<(Vec<Vec2>, Vec<Option<usize>>)> {
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
return err("a face of the sheet has no outline");
|
||||
}
|
||||
let moved = |i: usize| -> Option<(Vec2, Vec2)> {
|
||||
let (a, b) = (*pts.get(i % n)?, *pts.get((i + 1) % n)?);
|
||||
let d = (b - a).normalized()?;
|
||||
Some((a + Vec2::new(-d.y, d.x) * *by.get(i % n)?, d))
|
||||
};
|
||||
let (mut out, mut from) = (Vec::new(), Vec::new());
|
||||
for i in 0..n {
|
||||
let (Some((p0, d0)), Some((p1, d1))) = (moved(i + n - 1), moved(i)) else { return err("a face of the sheet has an edge without length") };
|
||||
let cross = d0.cross(d1);
|
||||
if cross.abs() > 1e-9 {
|
||||
out.push(p0 + d0 * ((p1 - p0).cross(d1) / cross));
|
||||
} else if d0.dot(d1) > 0.0 {
|
||||
let end = p0 + d0 * (p1 - p0).dot(d0);
|
||||
if end.dist(p1) > 1e-9 {
|
||||
out.push(end);
|
||||
from.push(None);
|
||||
}
|
||||
out.push(p1);
|
||||
} else {
|
||||
return err("the outline of a face of the sheet doubles back on itself");
|
||||
}
|
||||
from.push(Some(i));
|
||||
}
|
||||
// Every edge still runs the way it did, and the outline is still one simple loop.
|
||||
let m = out.len();
|
||||
let kept = (0..m).all(|j| match (from.get(j).copied().flatten().and_then(moved), out.get(j), out.get((j + 1) % m)) {
|
||||
(Some((_, d)), Some(&a), Some(&b)) => (b - a).dot(d) > 1e-9,
|
||||
_ => true,
|
||||
});
|
||||
if !kept || !(signed_area(&out) > 0.0) || polys_cross(&out, &out) {
|
||||
return err("the bends' setbacks and the gaps at the cut edges use up an edge of a face");
|
||||
}
|
||||
Ok((out, from))
|
||||
}
|
||||
|
||||
/// Give flange `i` its panel: `outline` in flat coordinates, counter-clockwise, its edge at the
|
||||
/// bend first trimmed to the end of the bend zone.
|
||||
fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) {
|
||||
let ba = sh.flanges.get(i).map_or(0.0, |f| sh.allowance(f));
|
||||
let Some(f) = sh.flanges.get_mut(i) else { return };
|
||||
// Its corners along the bend (u) and past the bend zone (v); those at the zone exactly on it.
|
||||
let uv: Vec<(f64, f64)> =
|
||||
outline.iter().map(|q| ((*q - f.p).dot(f.d), (*q - f.p).dot(f.n) - ba)).map(|(u, v)| (u, if v.abs() < 1e-6 { 0.0 } else { v })).collect();
|
||||
f.leg = uv.iter().fold(0.0, |m, (_, v)| m.max(*v));
|
||||
let level = |h: f64| -> Vec<f64> { uv.iter().filter(|(_, v)| (v - h).abs() < 1e-6).map(|(u, _)| *u).collect() };
|
||||
match (level(0.0).as_slice(), level(f.leg).as_slice()) {
|
||||
// Two corners at the bend and two on a far edge parallel to it: a flange's own panel,
|
||||
// with its run-ons past the bend's ends and its slanted ends.
|
||||
(&[a, b], &[c, d]) if uv.len() == 4 && f.leg > 1e-6 => {
|
||||
let zero = |x: f64| if x.abs() < 1e-12 { 0.0 } else { x };
|
||||
f.ext = (zero(-a.min(b)), zero(a.max(b) - f.len));
|
||||
f.slope = (zero((a.min(b) - c.min(d)) / f.leg), zero((c.max(d) - a.max(b)) / f.leg));
|
||||
}
|
||||
_ => f.shape = Some(uv.iter().map(|(u, v)| f.p + f.d * *u + f.n * (ba + *v)).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Names of the edges of `body` at the bend points, to find them again after the body changes.
|
||||
/// A point on no edge gets none: the conversion then says so, instead of bending the edge that
|
||||
/// happens to be nearest.
|
||||
pub fn bend_edge_names(st: &crate::ModelState, body: &str, pts: &[Vec3]) -> Vec<String> {
|
||||
let Some(b) = st.body(body) else { return Vec::new() };
|
||||
let (mesh, near) = (b.mesh(), (b.body.size() * 2e-3).max(1e-3));
|
||||
let on = |p: &Vec3| mesh.edges.iter().any(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) <= near));
|
||||
pts.iter().zip(crate::naming::edge_names_at(b, pts)).map(|(p, name)| if on(p) { name } else { String::new() }).collect()
|
||||
}
|
||||
|
||||
/// Convert body `mb` to a sheet under the rule `rule` with values `rv`: `face` is a point on the
|
||||
/// fixed face and `bends` are points on the sharp edges that become bends (the body's
|
||||
/// coordinates, mm). See the module docs.
|
||||
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<SheetBody> {
|
||||
if bends.is_empty() || bends.len() > MAX_BENDS {
|
||||
return err(format!("pick 1…{MAX_BENDS} bend edges"));
|
||||
}
|
||||
if !(rv.gap >= 0.0 && rv.bend_radius >= 0.0) {
|
||||
return err("the rule's gap and bend radius must not be negative");
|
||||
}
|
||||
let (b, mesh) = (&mb.body, mb.mesh());
|
||||
let size = b.size();
|
||||
if !(size.is_finite() && size > 0.0) {
|
||||
return err("the body has no size");
|
||||
}
|
||||
// Points closer than `tol` are one corner; a pick lies within `near` of what it picks.
|
||||
let (tol, near) = ((size * 1e-6).max(1e-6), (size * 2e-3).max(1e-3));
|
||||
let Some((_, f0)) = crate::appearance::nearest_face(mb, face).filter(|(d, _)| *d <= near) else {
|
||||
return err(format!("no face of the body at the fixed face point {}", at(face)));
|
||||
};
|
||||
let fixed = outline(b, &mesh, f0, tol).map_err(|e| DocError::Invalid(format!("the fixed face can't be a face of the sheet: {e}")))?;
|
||||
// The two faces along each bend edge.
|
||||
let mut picks = Vec::with_capacity(bends.len());
|
||||
for p in bends {
|
||||
let dist = |e: &Vec<Vec3>| e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||||
let edge = mesh.edges.iter().zip(&mesh.edge_faces).map(|(e, faces)| (dist(e), faces)).min_by(|x, y| x.0.total_cmp(&y.0));
|
||||
match edge.filter(|(d, _)| *d <= near).map(|(_, faces)| faces.as_slice()) {
|
||||
Some(&[x, y]) if x != y => picks.push((*p, (x as usize, y as usize))),
|
||||
Some(_) => return err(format!("the edge at the bend point {} does not lie between two faces", at(*p))),
|
||||
None => return err(format!("no edge of the body at the bend point {}", at(*p))),
|
||||
}
|
||||
}
|
||||
// A shell: the same small distance of material behind the fixed face and the faces at every
|
||||
// bend. It keeps its thickness, and a bend picked on one skin is that bend on the other too.
|
||||
let thin = behind(b, &mesh, f0).map(|(_, d)| d).filter(|d| *d > 0.0 && d * d * 16.0 <= area_about(&fixed.pts, fixed.n));
|
||||
let wall = |f: usize, d: f64| behind(b, &mesh, f).filter(|(_, e)| (e - d).abs() <= 1e-6 * d.max(1.0)).map(|(g, _)| g);
|
||||
let backs: Vec<Option<(usize, usize)>> = picks.iter().map(|(_, (x, y))| thin.and_then(|d| Some((wall(*x, d)?, wall(*y, d)?)))).collect();
|
||||
let shell = thin.filter(|_| backs.iter().all(Option::is_some));
|
||||
let t = shell.map_or(rv.thickness, |d| (d * 1e9).round() / 1e9);
|
||||
let r = rv.bend_radius;
|
||||
let joined = |x: usize, y: usize| mesh.edge_faces.iter().any(|faces| faces.contains(&(x as u32)) && faces.contains(&(y as u32)));
|
||||
let mut todo: Vec<(Vec3, Vec<(usize, usize)>)> = Vec::with_capacity(picks.len());
|
||||
for (&(p, pair), back) in picks.iter().zip(&backs) {
|
||||
let other = back.filter(|(x, y)| shell.is_some() && joined(*x, *y));
|
||||
todo.push((p, std::iter::once(pair).chain(other).collect()));
|
||||
}
|
||||
// The panels: the faces reached from the fixed face across the bends, each hanging off the
|
||||
// face it was reached from.
|
||||
let mut panels = BTreeMap::from([(f0, Panel { face: fixed, parent: None, angle: 0.0, setback: 0.0 })]);
|
||||
let mut order = vec![f0];
|
||||
loop {
|
||||
let waiting = todo.len();
|
||||
let mut rest = Vec::new();
|
||||
for (p, pairs) in todo {
|
||||
let reached = pairs.iter().find(|(x, y)| panels.contains_key(x) || panels.contains_key(y));
|
||||
let Some(&(x, y)) = reached else {
|
||||
rest.push((p, pairs));
|
||||
continue;
|
||||
};
|
||||
let (from, to) = if panels.contains_key(&x) { (x, y) } else { (y, x) };
|
||||
if let Some(seen) = panels.get(&to) {
|
||||
// The same bend picked twice is one bend; any other edge between two panels
|
||||
// would close a ring of bends, which no flat sheet folds into.
|
||||
if seen.parent == Some(from) || panels.get(&from).is_some_and(|k| k.parent == Some(to)) {
|
||||
continue;
|
||||
}
|
||||
return err(format!("the bend at {} closes a loop of bends (leave one of its edges unpicked: it is cut open)", at(p)));
|
||||
}
|
||||
let child = outline(b, &mesh, to, tol)
|
||||
.map_err(|e| DocError::Invalid(format!("the face across the bend at {} can't be a face of the sheet: {e}", at(p))))?;
|
||||
let Some(parent) = panels.get(&from) else { continue };
|
||||
let theta = parent.face.n.dot(child.n).clamp(-1.0, 1.0).acos();
|
||||
if !(theta > 1e-6 && theta < std::f64::consts::PI - 1e-6) {
|
||||
return err(format!("the faces at the bend at {} must meet at an angle between 0 and 180°", at(p)));
|
||||
}
|
||||
// Up: the child rises on the material side of its parent, which is behind its face.
|
||||
let o = parent.face.pts.first().copied().unwrap_or_default();
|
||||
let lift = child.pts.iter().map(|q| (*q - o).dot(parent.face.n)).fold(0.0, |m: f64, h| if h.abs() > m.abs() { h } else { m });
|
||||
let (angle, sharp) = if lift < 0.0 { (theta, r + t) } else { (-theta, r) };
|
||||
panels.insert(to, Panel { face: child, parent: Some(from), angle, setback: sharp * (theta / 2.0).tan() });
|
||||
order.push(to);
|
||||
}
|
||||
todo = rest;
|
||||
if todo.is_empty() || todo.len() == waiting {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some((p, _)) = todo.first() {
|
||||
return err(format!("the bend at {} does not connect to the fixed face through the other bends", at(*p)));
|
||||
}
|
||||
// The flat frame: on the fixed face, x along its longest edge, z into the material.
|
||||
let Some(base) = panels.get(&f0) else { return err("fixed face") };
|
||||
let corners = base.face.pts.iter().zip(base.face.pts.iter().cycle().skip(1));
|
||||
let longest = corners.max_by(|x, y| x.0.dist(*x.1).total_cmp(&y.0.dist(*y.1)));
|
||||
let frame = longest
|
||||
.and_then(|(a, b)| Some((*a, (*b - *a).normalized()?)))
|
||||
.and_then(|(a, x)| Plane::new(a, x, x.cross(base.face.n)))
|
||||
.ok_or_else(|| DocError::Invalid("the fixed face has no plane".into()))?;
|
||||
let mut sh = base_flange(&mb.name, rule, &RuleValues { thickness: t, ..*rv }, frame, Region2::default());
|
||||
// Lay the panels out, parents first: each is its face, seen through the inverse of its
|
||||
// fold, with its edges moved in by a bend's setback or a rip's cut.
|
||||
let mut queue: Vec<(usize, Option<usize>)> = vec![(f0, None)];
|
||||
let mut next = 0;
|
||||
while let Some(&(pf, fl)) = queue.get(next) {
|
||||
next += 1;
|
||||
let Some(panel) = panels.get(&pf) else { continue };
|
||||
let inv = mat_inverse(&sh.world(fl)).ok_or_else(|| DocError::Invalid("sheet panel frame".into()))?;
|
||||
let n = panel.face.pts.len();
|
||||
let (mut pts, mut by) = (Vec::with_capacity(n), Vec::with_capacity(n));
|
||||
for (i, p) in panel.face.pts.iter().enumerate() {
|
||||
let q = apply_point(&inv, *p);
|
||||
if !(q.z.abs() <= tol) {
|
||||
return err(format!("the face at {} does not lie where its bend puts it ({:.6} mm off)", at(*p), q.z));
|
||||
}
|
||||
pts.push(Vec2::new(q.x, q.y));
|
||||
let e = panel.face.pts.get((i + 1) % n).and_then(|b| (*b - *p).normalized()).unwrap_or(Vec3::X);
|
||||
by.push(match panel.face.across.get(i).copied().flatten().and_then(|g| Some((g, panels.get(&g)?))) {
|
||||
Some((g, _)) if panel.parent == Some(g) => panel.setback,
|
||||
Some((_, other)) if other.parent == Some(pf) => other.setback,
|
||||
Some((_, other)) => rip_cut(&panel.face, &other.face, e, t, rv.gap)?,
|
||||
None => 0.0,
|
||||
});
|
||||
}
|
||||
let mut across = panel.face.across.clone();
|
||||
if signed_area(&pts) < 0.0 {
|
||||
pts.reverse();
|
||||
turn(&mut by);
|
||||
turn(&mut across);
|
||||
}
|
||||
let first = panel.face.pts.first().copied().unwrap_or_default();
|
||||
let (trimmed, from) = inset(&pts, &by).map_err(|e| DocError::Invalid(format!("{e} (the face at {})", at(first))))?;
|
||||
match fl {
|
||||
None => sh.base.outer = Loop2::polygon(&trimmed.iter().map(|q| snap(*q)).collect::<Vec<_>>()),
|
||||
Some(i) => set_panel(&mut sh, i, &trimmed),
|
||||
}
|
||||
// Its children: flanges on the trimmed edges they bend at.
|
||||
let m = trimmed.len();
|
||||
for &g in &order {
|
||||
let Some(child) = panels.get(&g).filter(|c| c.parent == Some(pf)) else { continue };
|
||||
let runs: Vec<usize> =
|
||||
(0..m).filter(|j| from.get(*j).copied().flatten().and_then(|i| across.get(i).copied().flatten()) == Some(g)).collect();
|
||||
let (a, b) = match runs.as_slice() {
|
||||
&[j] => (trimmed.get(j).copied().map(snap), trimmed.get((j + 1) % m).copied().map(snap)),
|
||||
_ => return err("not supported yet: two faces of the sheet that meet along more than one edge"),
|
||||
};
|
||||
let (Some(a), Some(b)) = (a, b) else { continue };
|
||||
let Some(d) = (b - a).normalized() else { return err("a bend edge has no length") };
|
||||
sh.flanges.push(SheetFlange {
|
||||
parent: fl,
|
||||
p: a,
|
||||
d,
|
||||
len: a.dist(b),
|
||||
n: Vec2::new(d.y, -d.x),
|
||||
angle: child.angle,
|
||||
radius: r,
|
||||
leg: 0.0,
|
||||
ext: (0.0, 0.0),
|
||||
slope: (0.0, 0.0),
|
||||
shape: None,
|
||||
tag: String::new(),
|
||||
tear: (false, false),
|
||||
});
|
||||
queue.push((g, Some(sh.flanges.len() - 1)));
|
||||
}
|
||||
}
|
||||
Ok(sh)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn edges_move_in_by_their_own_amounts_and_step_along_one_line() {
|
||||
let v = Vec2::new;
|
||||
let square = [v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)];
|
||||
let (p, from) = inset(&square, &[1.0, 0.0, 2.0, 0.0]).unwrap();
|
||||
assert_eq!(p, vec![v(0.0, 1.0), v(10.0, 1.0), v(10.0, 8.0), v(0.0, 8.0)]);
|
||||
assert_eq!(from, vec![Some(0), Some(1), Some(2), Some(3)]);
|
||||
// Two edges in one line that move by different amounts: a square step between them,
|
||||
// so 5 x 2 goes from the 10 x 10.
|
||||
let split = [v(0.0, 0.0), v(5.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)];
|
||||
let (p, from) = inset(&split, &[0.0, 2.0, 0.0, 0.0, 0.0]).unwrap();
|
||||
assert_eq!(p, vec![v(0.0, 0.0), v(5.0, 0.0), v(5.0, 2.0), v(10.0, 2.0), v(10.0, 10.0), v(0.0, 10.0)]);
|
||||
assert_eq!(from, vec![Some(0), None, Some(1), Some(2), Some(3), Some(4)]);
|
||||
// A slanted end keeps its line: the corner slides along it (2 up a 45° edge is 2 along).
|
||||
let slanted = [v(0.0, 0.0), v(10.0, 0.0), v(15.0, 5.0), v(0.0, 5.0)];
|
||||
let (p, _) = inset(&slanted, &[2.0, 0.0, 0.0, 0.0]).unwrap();
|
||||
assert!(p[1].dist(v(12.0, 2.0)) < 1e-12 && p[0].dist(v(0.0, 2.0)) < 1e-12, "{p:?}");
|
||||
// Setbacks longer than the face leave nothing of it.
|
||||
assert!(inset(&square, &[6.0, 0.0, 6.0, 0.0]).is_err());
|
||||
assert!(inset(&square, &[f64::NAN, 0.0, 0.0, 0.0]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -386,6 +386,25 @@ fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() {
|
||||
assert!(!a.mesh().triangles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_inserted_at_a_marker_inside_a_timeline_folder_joins_it() {
|
||||
let mut doc = Document::new("folder insertion");
|
||||
let first = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("First")).unwrap();
|
||||
let second =
|
||||
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("Second")).unwrap();
|
||||
doc.timeline_groups.push(TimelineGroup { id: 100, name: "Pair".into(), parent: None, features: vec![first, second] });
|
||||
doc.marker = Some(1);
|
||||
|
||||
let inserted =
|
||||
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("Inserted")).unwrap();
|
||||
|
||||
assert_eq!(doc.features.iter().map(|feature| feature.id).collect::<Vec<_>>(), vec![first, inserted, second]);
|
||||
assert_eq!(doc.timeline_groups[0].features, vec![first, inserted, second]);
|
||||
assert_eq!(doc.marker, Some(2));
|
||||
assert!(non_contiguous_timeline_group(&doc.features, &doc.timeline_groups).is_none());
|
||||
assert!(Document::from_json(&doc.to_json()).is_ok(), "the inserted feature must leave a reopenable design");
|
||||
}
|
||||
|
||||
/// A construction plane that is its own side resolves through the model and back into the
|
||||
/// document. The depth count has to survive that hop, or the recursion overflows the stack and
|
||||
/// aborts the process, which no panic guard can intercept.
|
||||
|
||||
@@ -5,6 +5,35 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Runs in PR CI as well as the heavier fixed-seed suite. Two pattern copies are
|
||||
/// disconnected, and one cylinder overlaps the block by half its volume.
|
||||
#[test]
|
||||
fn partial_face_contact_pattern_has_analytic_material() {
|
||||
// Repeated construction exposes re-imprinting a cylinder through both of its
|
||||
// side faces, which can create a redundant arc along the first imprint.
|
||||
for _ in 0..8 {
|
||||
check_partial_face_contact_pattern();
|
||||
}
|
||||
}
|
||||
|
||||
fn check_partial_face_contact_pattern() {
|
||||
let s = replay(include_str!("../../../examples/regressions/partial_face_contact_pattern.json"));
|
||||
let (mut volume, mut area, mut shells) = (0.0, 0.0, 0);
|
||||
for b in &s.model.state().bodies {
|
||||
assert!(b.body.validity().is_empty(), "{:?}", b.body.validity());
|
||||
let m = b.body.tessellate(1e-5).unwrap().measure();
|
||||
volume += m.volume;
|
||||
area += m.area;
|
||||
shells += b.body.shell_count();
|
||||
}
|
||||
let expected_volume = 25.0 * 47.5 * 37.5 + 3.5 * std::f64::consts::PI * 25.0 * 2.5;
|
||||
let expected_area = 2.0 * (25.0 * 47.5 + 25.0 * 37.5 + 47.5 * 37.5) + 237.5 * std::f64::consts::PI - 25.0;
|
||||
assert!((volume - expected_volume).abs() < 0.02, "{volume} expected {expected_volume}");
|
||||
assert!((area - expected_area).abs() < 0.02, "{area} expected {expected_area}");
|
||||
assert_eq!(shells, 3);
|
||||
println!("#674 volume {volume:.6} / {expected_volume:.6} mm³; area {area:.6} / {expected_area:.6} mm²; {shells} shells");
|
||||
}
|
||||
|
||||
fn replay(script: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(script).unwrap();
|
||||
@@ -88,3 +117,101 @@ 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);
|
||||
}
|
||||
|
||||
/// #679: one sketch of a 2 × 2 grid of 10 mm squares; three cells extruded up 10 mm (XO/XX) and
|
||||
/// three down 10 mm, joined (XX/OX). The two L's meet back to back over two whole cells, along
|
||||
/// an edge and at the centre: one solid of 6 cells × 10 mm × 100 mm².
|
||||
#[test]
|
||||
fn ls_extruded_up_and_down_from_one_grid_join() {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Grid"}));
|
||||
for (p0, p1) in [([-10, -10], [0, 0]), ([0, -10], [10, 0]), ([-10, 0], [0, 10]), ([0, 0], [10, 10])] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let cells = |pts: &[[f64; 2]]| Value::Array(pts.iter().map(|p| json!({"point": p})).collect());
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Grid", "profiles": cells(&[[-5., 5.], [-5., -5.], [5., -5.]]), "distance": 10, "body_name": "Logo"}),
|
||||
);
|
||||
let reply = run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Grid", "profiles": cells(&[[-5., 5.], [5., 5.], [5., -5.]]), "distance": "10 mm", "direction": "negative", "operation": "join"}),
|
||||
);
|
||||
assert!(reply["warning"].as_str().is_none_or(|w| !w.contains("tool displacement")), "{reply}");
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
let body = &st.body("Logo").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||||
let v = volume(&s, "Logo");
|
||||
assert!((v - 6000.0).abs() < 1e-6, "{v}");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -72,6 +72,8 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for f in feats {
|
||||
let mut kind = f.kind.clone();
|
||||
kind.remap_refs(&sketches, &names);
|
||||
// A body made by an earlier copy is the one this copy acts on (#613).
|
||||
kind.remap_bodies(&bodies);
|
||||
if let FeatureKind::Sketch { plane, .. } = &mut kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
||||
{
|
||||
@@ -208,4 +210,47 @@ mod tests {
|
||||
let mut t = Session::default();
|
||||
assert!(t.execute("timeline.paste", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_at_a_marker_inside_a_timeline_folder_joins_the_folder() {
|
||||
let mut s = Session::default();
|
||||
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Source", "SourceBody", 40)] {
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
|
||||
);
|
||||
}
|
||||
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap();
|
||||
run(&mut s, "timeline.copy", json!({"features": ["Source"]}));
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 1}));
|
||||
|
||||
let pasted = run(&mut s, "timeline.paste", json!({}))["pasted"][0]["id"].as_u64().unwrap();
|
||||
let folder = s.doc.timeline_groups.iter().find(|folder| folder.id == group).unwrap();
|
||||
let first = s.doc.find_feature("First").unwrap().id;
|
||||
let second = s.doc.find_feature("Second").unwrap().id;
|
||||
assert_eq!(folder.features, vec![first, pasted, second]);
|
||||
assert!(solidcraft_doc::Document::from_json(&s.doc.to_json()).is_ok(), "pasting inside a folder must leave a reopenable design");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_fillet_rounds_the_copied_body_it_names() {
|
||||
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, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 10, "operation": "new", "body_name": "Block", "name": "Block"}));
|
||||
run(&mut s, "solid.fillet", json!({"body": "Block", "radius": 2, "edges": [[20, 0, 10], [20, 20, 10]], "name": "Round"}));
|
||||
let one = volume(&mut s);
|
||||
assert!(one < 8000.0 - 1.0, "the fillet removes material: {one}");
|
||||
run(&mut s, "timeline.copy", json!({"features": ["S", "Block", "Round"]}));
|
||||
let r = run(&mut s, "timeline.paste", json!({"translate": [0, 40, 0]}));
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let round = r["pasted"][2]["name"].as_str().unwrap_or_default().to_string();
|
||||
let def = run(&mut s, "timeline.inspect", json!({"feature": round}));
|
||||
assert_eq!(def["definition"]["body"], "Block (copy)", "{def}");
|
||||
let m = run(&mut s, "inspect.measure", json!({}));
|
||||
assert_eq!(m["body_count"], 2, "{m}");
|
||||
assert!((volume(&mut s) - 2.0 * one).abs() < 1e-6, "both blocks are rounded: {m}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/17\""));
|
||||
|
||||
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,12 +24,29 @@ 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.group", "New Feature Folder", group)
|
||||
.icon("folder")
|
||||
.doc_only()
|
||||
.params("name?; features?: [id|name]; parent?: folder id (features leave any other timeline folder)"),
|
||||
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup)
|
||||
.doc_only()
|
||||
.params("group: id | groups: [ids] (features remain in the timeline)"),
|
||||
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).doc_only().params("group: id, name"),
|
||||
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group)
|
||||
.doc_only()
|
||||
.params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
|
||||
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).params(
|
||||
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block",
|
||||
),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
|
||||
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
|
||||
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder)
|
||||
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
|
||||
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).params(
|
||||
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; refused when a feature that worked would fail",
|
||||
),
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", redefine)
|
||||
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"),
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents)
|
||||
@@ -163,18 +182,324 @@ 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}))
|
||||
}
|
||||
|
||||
fn timeline_group_id(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
timeline_group_id_field(s, p, "group", cmd)
|
||||
}
|
||||
|
||||
fn timeline_group_id_field(s: &Session, p: &Value, field: &str, cmd: &str) -> Result<u64> {
|
||||
let id = p.get(field).and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{field}` must be a timeline folder id")))?;
|
||||
if s.doc.timeline_groups.iter().any(|group| group.id == id) { Ok(id) } else { Err(bad(cmd, format!("no timeline folder {id}"))) }
|
||||
}
|
||||
|
||||
fn group_features(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
|
||||
let values = match p.get("features") {
|
||||
Some(Value::Array(values)) => values,
|
||||
None if !required => return Ok(Vec::new()),
|
||||
_ => return Err(bad(cmd, "`features` must be a list of feature ids or names")),
|
||||
};
|
||||
if values.is_empty() && required {
|
||||
return Err(bad(cmd, "`features` must list at least one feature"));
|
||||
}
|
||||
if values.len() > 10_000 {
|
||||
return Err(bad(cmd, "too many features"));
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for value in values {
|
||||
let id = feature_id(s, Some(value), cmd)?;
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn group_ids(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
|
||||
let values = match p.get("groups") {
|
||||
Some(Value::Array(values)) => values,
|
||||
None if !required => return Ok(Vec::new()),
|
||||
None => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
|
||||
_ => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
|
||||
};
|
||||
if values.is_empty() && required {
|
||||
return Err(bad(cmd, "`groups` must list at least one folder"));
|
||||
}
|
||||
if values.len() > 10_000 {
|
||||
return Err(bad(cmd, "too many folders"));
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for value in values {
|
||||
let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain folder ids"))?;
|
||||
if !s.doc.timeline_groups.iter().any(|group| group.id == id) {
|
||||
return Err(bad(cmd, format!("no timeline folder {id}")));
|
||||
}
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn group_move_would_cycle(s: &Session, target: u64, sources: &[u64]) -> bool {
|
||||
let sources: std::collections::BTreeSet<u64> = sources.iter().copied().collect();
|
||||
let parents: std::collections::BTreeMap<u64, Option<u64>> = s.doc.timeline_groups.iter().map(|group| (group.id, group.parent)).collect();
|
||||
let mut current = Some(target);
|
||||
let mut steps = 0usize;
|
||||
while let Some(id) = current {
|
||||
if sources.contains(&id) {
|
||||
return true;
|
||||
}
|
||||
steps += 1;
|
||||
if steps > s.doc.timeline_groups.len() {
|
||||
return true;
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn group_feature_ids(doc: &solidcraft_doc::Document, roots: &[u64]) -> std::collections::BTreeSet<u64> {
|
||||
let by_id: std::collections::BTreeMap<u64, &solidcraft_doc::TimelineGroup> = doc.timeline_groups.iter().map(|group| (group.id, group)).collect();
|
||||
let mut children = std::collections::BTreeMap::<u64, Vec<u64>>::new();
|
||||
for group in &doc.timeline_groups {
|
||||
if let Some(parent) = group.parent {
|
||||
children.entry(parent).or_default().push(group.id);
|
||||
}
|
||||
}
|
||||
let mut pending = roots.to_vec();
|
||||
let mut visited = std::collections::BTreeSet::new();
|
||||
let mut features = std::collections::BTreeSet::new();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !visited.insert(id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(group) = by_id.get(&id) {
|
||||
features.extend(group.features.iter().copied());
|
||||
pending.extend(children.get(&id).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
features
|
||||
}
|
||||
|
||||
fn sort_group_features(doc: &mut solidcraft_doc::Document) {
|
||||
let positions: std::collections::BTreeMap<u64, usize> = doc.features.iter().enumerate().map(|(index, feature)| (feature.id, index)).collect();
|
||||
for group in &mut doc.timeline_groups {
|
||||
group.features.sort_by_key(|feature| positions.get(feature).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
fn require_contiguous_groups(doc: &solidcraft_doc::Document, cmd: &str) -> Result<()> {
|
||||
let Some(id) = solidcraft_doc::non_contiguous_timeline_group(&doc.features, &doc.timeline_groups) else { return Ok(()) };
|
||||
let name = doc.timeline_groups.iter().find(|group| group.id == id).map(|group| group.name.as_str()).unwrap_or("unknown");
|
||||
Err(bad(cmd, format!("timeline folder `{name}` must contain one contiguous run of features")))
|
||||
}
|
||||
|
||||
fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.group";
|
||||
let mut features = group_features(s, p, false, cmd)?;
|
||||
let groups = group_ids(s, p, false, cmd)?;
|
||||
let name = match str_(p, "name").map(str::trim).filter(|name| !name.is_empty()) {
|
||||
Some(name) if name.len() <= 128 => name.to_string(),
|
||||
Some(_) => return Err(bad(cmd, "`name` must be 1…128 characters")),
|
||||
None => format!("Folder{}", s.doc.timeline_groups.len() + 1),
|
||||
};
|
||||
let id = s
|
||||
.doc
|
||||
.timeline_groups
|
||||
.iter()
|
||||
.map(|group| group.id)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| bad(cmd, "no more timeline folder ids are available"))?;
|
||||
let parent = match p.get("parent") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(timeline_group_id_field(s, p, "parent", cmd)?),
|
||||
};
|
||||
if let Some(parent) = parent
|
||||
&& group_move_would_cycle(s, parent, &groups)
|
||||
{
|
||||
return Err(bad(cmd, "a new folder cannot contain an ancestor of its parent"));
|
||||
}
|
||||
let nested = group_feature_ids(&s.doc, &groups);
|
||||
features.retain(|feature| !nested.contains(feature));
|
||||
let mut next = (*s.doc).clone();
|
||||
for group in &mut next.timeline_groups {
|
||||
group.features.retain(|feature| !features.contains(feature));
|
||||
if groups.contains(&group.id) {
|
||||
group.parent = Some(id);
|
||||
}
|
||||
}
|
||||
next.timeline_groups.push(solidcraft_doc::TimelineGroup { id, name: name.clone(), parent, features });
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"group": id, "name": name, "parent": parent, "groups": groups}))
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.ungroup";
|
||||
let ids = match p.get("groups") {
|
||||
Some(Value::Array(values)) if !values.is_empty() && values.len() <= 10_000 => {
|
||||
let mut ids = Vec::new();
|
||||
for value in values {
|
||||
let id = value.as_u64().ok_or_else(|| bad(cmd, "`groups` must contain timeline folder ids"))?;
|
||||
if !s.doc.timeline_groups.iter().any(|group| group.id == id) {
|
||||
return Err(bad(cmd, format!("no timeline folder {id}")));
|
||||
}
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
Some(Value::Array(_)) => return Err(bad(cmd, "`groups` must list 1…10000 timeline folder ids")),
|
||||
Some(_) => return Err(bad(cmd, "`groups` must be a list of timeline folder ids")),
|
||||
None => vec![timeline_group_id(s, p, cmd)?],
|
||||
};
|
||||
let parents: std::collections::BTreeMap<u64, Option<u64>> =
|
||||
s.doc.timeline_groups.iter().filter(|group| ids.contains(&group.id)).map(|group| (group.id, group.parent)).collect();
|
||||
let surviving_parent = |mut parent: Option<u64>| {
|
||||
let mut steps = 0usize;
|
||||
while let Some(id) = parent.filter(|id| ids.contains(id)) {
|
||||
parent = parents.get(&id).copied().flatten();
|
||||
steps += 1;
|
||||
if steps > parents.len() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
parent
|
||||
};
|
||||
let mut next = (*s.doc).clone();
|
||||
let moved_features: Vec<(Option<u64>, Vec<u64>)> = next
|
||||
.timeline_groups
|
||||
.iter()
|
||||
.filter(|group| ids.contains(&group.id))
|
||||
.map(|group| (surviving_parent(group.parent), group.features.clone()))
|
||||
.collect();
|
||||
for group in &mut next.timeline_groups {
|
||||
if ids.contains(&group.id) {
|
||||
continue;
|
||||
}
|
||||
group.parent = surviving_parent(group.parent);
|
||||
for (_, features) in moved_features.iter().filter(|(parent, _)| *parent == Some(group.id)) {
|
||||
group.features.extend(features.iter().copied());
|
||||
}
|
||||
}
|
||||
next.timeline_groups.retain(|group| !ids.contains(&group.id));
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"groups": ids}))
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.rename_group";
|
||||
let id = timeline_group_id(s, p, cmd)?;
|
||||
let name = str_(p, "name")
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty() && name.len() <= 128)
|
||||
.ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
|
||||
if let Some(group) = s.doc_mut().timeline_groups.iter_mut().find(|group| group.id == id) {
|
||||
group.name = name.to_string();
|
||||
}
|
||||
Ok(json!({"group": id, "name": name}))
|
||||
}
|
||||
|
||||
fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.move_to_group";
|
||||
let features = group_features(s, p, false, cmd)?;
|
||||
let groups = group_ids(s, p, false, cmd)?;
|
||||
if features.is_empty() && groups.is_empty() {
|
||||
return Err(bad(cmd, "list at least one feature or folder"));
|
||||
}
|
||||
let target = match p.get("group") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(timeline_group_id(s, p, cmd)?),
|
||||
};
|
||||
if let Some(target) = target
|
||||
&& group_move_would_cycle(s, target, &groups)
|
||||
{
|
||||
return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants"));
|
||||
}
|
||||
let mut next = (*s.doc).clone();
|
||||
for group in &mut next.timeline_groups {
|
||||
group.features.retain(|feature| !features.contains(feature));
|
||||
if Some(group.id) == target {
|
||||
group.features.extend(features.iter().copied());
|
||||
}
|
||||
}
|
||||
for group in &mut next.timeline_groups {
|
||||
if groups.contains(&group.id) {
|
||||
group.parent = target;
|
||||
}
|
||||
}
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"features": features, "groups": groups, "group": target}))
|
||||
}
|
||||
|
||||
fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.move_many";
|
||||
let mut features = group_features(s, p, false, cmd)?;
|
||||
let groups = group_ids(s, p, false, cmd)?;
|
||||
if features.is_empty() && groups.is_empty() {
|
||||
return Err(bad(cmd, "list at least one feature or folder"));
|
||||
}
|
||||
let target = match p.get("group") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(timeline_group_id(s, p, cmd)?),
|
||||
};
|
||||
if let Some(target) = target
|
||||
&& group_move_would_cycle(s, target, &groups)
|
||||
{
|
||||
return Err(bad(cmd, "a folder cannot be moved into itself or one of its descendants"));
|
||||
}
|
||||
let nested = group_feature_ids(&s.doc, &groups);
|
||||
features.retain(|feature| !nested.contains(feature));
|
||||
let moving: Vec<u64> =
|
||||
s.doc.features.iter().filter(|feature| nested.contains(&feature.id) || features.contains(&feature.id)).map(|feature| feature.id).collect();
|
||||
let mut next = (*s.doc).clone();
|
||||
for group in &mut next.timeline_groups {
|
||||
group.features.retain(|feature| !features.contains(feature));
|
||||
if Some(group.id) == target {
|
||||
group.features.extend(features.iter().copied());
|
||||
}
|
||||
if groups.contains(&group.id) {
|
||||
group.parent = target;
|
||||
}
|
||||
}
|
||||
sort_group_features(&mut next);
|
||||
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
|
||||
let (next, ordered) = reorder_features(s, next, &moving, slot, cmd)?;
|
||||
if next != *s.doc {
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot}))
|
||||
}
|
||||
|
||||
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = s.doc.features.len();
|
||||
let pos = match p.get("feature") {
|
||||
@@ -322,6 +647,20 @@ 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));
|
||||
}
|
||||
}
|
||||
// New bend edges for a conversion: the names of the body's edges there now.
|
||||
if let (FeatureKind::SheetConvert { bends: a, body, .. }, FeatureKind::SheetConvert { bends: b, body: was, .. }) = (&nf.kind, &f.kind)
|
||||
&& (a != b || body != was)
|
||||
{
|
||||
nf.edge_names = solidcraft_doc::sheet::bend_edge_names(&s.model.state_before(id), body, 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)?;
|
||||
@@ -397,6 +736,8 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = next.features.remove(from);
|
||||
let name = f.name.clone();
|
||||
next.features.insert(to, f);
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
// Everything that evaluated before must still evaluate.
|
||||
let mut after = solidcraft_doc::Model::new();
|
||||
after.evaluate(&next);
|
||||
@@ -409,16 +750,78 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"feature": id, "position": to}))
|
||||
}
|
||||
|
||||
fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.reorder_many";
|
||||
let ids = group_features(s, p, true, cmd)?;
|
||||
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
|
||||
let (next, ordered) = reorder_features(s, (*s.doc).clone(), &ids, slot, cmd)?;
|
||||
if next != *s.doc {
|
||||
*s.doc_mut() = next;
|
||||
}
|
||||
Ok(json!({"features": ordered, "position": slot.min(s.doc.features.len())}))
|
||||
}
|
||||
|
||||
fn reorder_features(
|
||||
s: &Session,
|
||||
mut next: solidcraft_doc::Document,
|
||||
ids: &[u64],
|
||||
slot: usize,
|
||||
cmd: &str,
|
||||
) -> Result<(solidcraft_doc::Document, Vec<u64>)> {
|
||||
let n = next.features.len();
|
||||
let slot = slot.min(n);
|
||||
let selected: std::collections::BTreeSet<u64> = ids.iter().copied().collect();
|
||||
let ordered: Vec<u64> = next.features.iter().filter(|feature| selected.contains(&feature.id)).map(|feature| feature.id).collect();
|
||||
let before_slot = next.features.iter().take(slot).filter(|feature| selected.contains(&feature.id)).count();
|
||||
let insert_at = slot.saturating_sub(before_slot).min(n.saturating_sub(ordered.len()));
|
||||
|
||||
let mut moving = Vec::new();
|
||||
next.features.retain(|feature| {
|
||||
if selected.contains(&feature.id) {
|
||||
moving.push(feature.clone());
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
let unchanged = s.doc.features.iter().map(|feature| feature.id).eq(next.features[..insert_at]
|
||||
.iter()
|
||||
.chain(moving.iter())
|
||||
.chain(next.features[insert_at..].iter())
|
||||
.map(|feature| feature.id));
|
||||
next.features.splice(insert_at..insert_at, moving);
|
||||
sort_group_features(&mut next);
|
||||
require_contiguous_groups(&next, cmd)?;
|
||||
if !unchanged {
|
||||
let mut after = solidcraft_doc::Model::new();
|
||||
after.evaluate(&next);
|
||||
let broken = newly_broken(&s.model, &after);
|
||||
if !broken.is_empty() {
|
||||
let list: Vec<String> = broken.iter().map(|(name, error)| format!("{name} ({error})")).collect();
|
||||
return Err(bad(cmd, format!("moving the selected features there breaks {}", list.join("; "))));
|
||||
}
|
||||
}
|
||||
Ok((next, ordered))
|
||||
}
|
||||
|
||||
fn input_keys(kind: &FeatureKind) -> Vec<(String, solidcraft_doc::expr::Kind)> {
|
||||
let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect();
|
||||
if let FeatureKind::Fillet { style, .. } = kind {
|
||||
if let Some((key, _)) = keys.first_mut() {
|
||||
*key = "fillet.radius".into();
|
||||
}
|
||||
if let solidcraft_doc::FilletStyle::Variable { mid, .. } = style {
|
||||
for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) {
|
||||
*key = format!("fillet.mid.{}", position.to_bits());
|
||||
match style {
|
||||
solidcraft_doc::FilletStyle::Variable { mid, .. } => {
|
||||
for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) {
|
||||
*key = format!("fillet.mid.{}", position.to_bits());
|
||||
}
|
||||
}
|
||||
solidcraft_doc::FilletStyle::Spline { .. } => {
|
||||
for (i, (key, _)) in keys.iter_mut().enumerate().skip(1) {
|
||||
*key = format!("fillet.control.{i}");
|
||||
}
|
||||
}
|
||||
solidcraft_doc::FilletStyle::Constant | solidcraft_doc::FilletStyle::Chord => {}
|
||||
}
|
||||
}
|
||||
keys
|
||||
|
||||
@@ -310,3 +310,163 @@ fn inserting_variable_fillet_control_preserves_parameters_at_existing_positions(
|
||||
assert_eq!(length["value"], 3.0);
|
||||
assert_eq!(width["value"], 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_folders_are_command_backed_saved_undoable_and_follow_feature_ids() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[10, 5, 10]], "radius": 1, "name": "Round"}));
|
||||
let base = s.doc.find_feature("Base").unwrap().id;
|
||||
let round = s.doc.find_feature("Round").unwrap().id;
|
||||
|
||||
let made = run(&mut s, "timeline.group", json!({"name": "Housing", "features": ["Base", round]}));
|
||||
let group = made["group"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round]);
|
||||
run(&mut s, "timeline.rename", json!({"feature": base, "name": "Foundation"}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round], "folders follow stable ids, not names");
|
||||
run(&mut s, "timeline.rename_group", json!({"group": group, "name": "Main housing"}));
|
||||
run(&mut s, "timeline.move_to_group", json!({"features": [round], "group": null}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![base]);
|
||||
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.timeline_groups, s.doc.timeline_groups);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![base, round]);
|
||||
run(&mut s, "timeline.delete", json!({"features": [base]}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features, vec![round], "deleted features leave folders cleanly");
|
||||
run(&mut s, "timeline.ungroup", json!({"group": group}));
|
||||
assert!(s.doc.timeline_groups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_reorders_selected_features_as_one_block_and_removes_folders_in_one_step() {
|
||||
let mut s = Session::default();
|
||||
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
|
||||
);
|
||||
}
|
||||
let names = |session: &Session| session.doc.features.iter().map(|feature| feature.name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&s), vec!["First", "Second", "Third"]);
|
||||
|
||||
run(&mut s, "timeline.reorder_many", json!({"features": ["First", "Third"], "position": 3}));
|
||||
assert_eq!(names(&s), vec!["Second", "First", "Third"], "selected features keep their relative order");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&s), vec!["First", "Second", "Third"]);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(names(&s), vec!["Second", "First", "Third"]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
|
||||
let first = run(&mut s, "timeline.group", json!({"name": "One", "features": ["First"]}))["group"].as_u64().unwrap();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.timeline_groups.is_empty(), "creating a folder is undoable");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.timeline_groups.first().map(|group| group.id), Some(first));
|
||||
let second = run(&mut s, "timeline.group", json!({"name": "Two", "features": ["Second"]}))["group"].as_u64().unwrap();
|
||||
run(&mut s, "timeline.ungroup", json!({"groups": [first, second]}));
|
||||
assert!(s.doc.timeline_groups.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.timeline_groups.len(), 2, "removing several folders is one undoable command");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.doc.timeline_groups.is_empty(), "removing folders is redoable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_folders_refuse_unknown_non_contiguous_and_split_contents() {
|
||||
let mut s = Session::default();
|
||||
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
|
||||
);
|
||||
}
|
||||
let before = s.doc.clone();
|
||||
let error = s.execute("timeline.group", &json!({"features": ["First", "Third"]})).unwrap_err().to_string();
|
||||
assert!(error.contains("contiguous"), "{error}");
|
||||
assert_eq!(s.doc, before);
|
||||
|
||||
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap();
|
||||
let before = s.doc.clone();
|
||||
let error = s.execute("timeline.reorder_many", &json!({"features": ["First"], "position": 3})).unwrap_err().to_string();
|
||||
assert!(error.contains("contiguous"), "{error}");
|
||||
assert_eq!(s.doc, before, "a refused reorder must not split the folder");
|
||||
|
||||
for params in [json!({"features": [999_u64], "group": group}), json!({"groups": [999_u64], "group": group})] {
|
||||
let error = s.execute("timeline.move_to_group", ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains("no feature") || error.contains("no timeline folder"), "{error}");
|
||||
}
|
||||
|
||||
let mut damaged: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
let first = s.doc.find_feature("First").unwrap().id;
|
||||
let third = s.doc.find_feature("Third").unwrap().id;
|
||||
damaged["timeline_groups"] = json!([{"id": 1, "name": "Broken", "features": [first, third]}]);
|
||||
let error = solidcraft_doc::Document::from_json(&damaged.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("not contiguous"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_move_many_is_atomic_and_one_undo_step() {
|
||||
let mut s = Session::default();
|
||||
for (name, body, x) in [("First", "FirstBody", 0), ("Second", "SecondBody", 20), ("Third", "ThirdBody", 40)] {
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new_body", "name": name, "body_name": body}),
|
||||
);
|
||||
}
|
||||
let group = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First"]}))["group"].as_u64().unwrap();
|
||||
let before = (*s.doc).clone();
|
||||
run(&mut s, "timeline.move_many", json!({"features": ["Third"], "group": group, "position": 1}));
|
||||
assert_eq!(s.doc.features.iter().map(|feature| feature.name.as_str()).collect::<Vec<_>>(), vec!["First", "Third", "Second"]);
|
||||
assert_eq!(s.doc.timeline_groups[0].features.len(), 2);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc, before, "one undo restores both order and folder membership");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.timeline_groups[0].features.len(), 2);
|
||||
|
||||
let child = run(&mut s, "timeline.group", json!({"name": "Child", "features": ["Second"]}))["group"].as_u64().unwrap();
|
||||
let before_parent = (*s.doc).clone();
|
||||
let parent = run(&mut s, "timeline.group", json!({"name": "Parent", "groups": [group, child]}))["group"].as_u64().unwrap();
|
||||
assert!(s.doc.timeline_groups.iter().filter(|folder| folder.parent == Some(parent)).count() == 2);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc, before_parent, "creating a parent for selected folders is one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_timeline_move_many_keeps_folder_membership_and_order() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[10, 5, 10]], "radius": 1, "name": "Round"}));
|
||||
let group = run(&mut s, "timeline.group", json!({"name": "Destination"}))["group"].as_u64().unwrap();
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let error = s.execute("timeline.move_many", &json!({"features": ["Base"], "group": group, "position": 2})).unwrap_err().to_string();
|
||||
assert!(error.contains("breaks"), "{error}");
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo.len(), undo, "a refused atomic move adds no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_folders_can_nest_and_reject_cycles() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Base"}));
|
||||
let root = run(&mut s, "timeline.group", json!({"name": "Root"}))["group"].as_u64().unwrap();
|
||||
let child = run(&mut s, "timeline.group", json!({"name": "Child", "parent": root}))["group"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, Some(root));
|
||||
run(&mut s, "timeline.move_to_group", json!({"groups": [child], "group": null}));
|
||||
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, None);
|
||||
run(&mut s, "timeline.move_to_group", json!({"groups": [child], "group": root}));
|
||||
let error = s.execute("timeline.move_to_group", &json!({"groups": [root], "group": child})).unwrap_err().to_string();
|
||||
assert!(error.contains("itself") || error.contains("descendant"), "{error}");
|
||||
let before = s.doc.clone();
|
||||
let error = s.execute("timeline.move_many", &json!({"groups": [root], "group": child, "position": 0})).unwrap_err().to_string();
|
||||
assert!(error.contains("itself") || error.contains("descendant"), "{error}");
|
||||
assert_eq!(s.doc, before);
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.timeline_groups, s.doc.timeline_groups);
|
||||
run(&mut s, "timeline.ungroup", json!({"group": root}));
|
||||
assert_eq!(s.doc.timeline_groups.iter().find(|group| group.id == child).unwrap().parent, None, "deleting a folder promotes its children");
|
||||
}
|
||||
|
||||
@@ -20,7 +20,12 @@ fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
|
||||
/// The outward normal of the planar body face at `p`.
|
||||
pub(super) fn face_normal(s: &Session, p: Vec3) -> Option<Vec3> {
|
||||
let (m, f, d, _) = face_at(s, p)?;
|
||||
face_normal_in(s, p, None)
|
||||
}
|
||||
|
||||
/// The outward normal of the planar face at `p` of `body` (any body when `None`).
|
||||
pub(super) fn face_normal_in(s: &Session, p: Vec3, body: Option<&str>) -> Option<Vec3> {
|
||||
let (m, f, d, _) = face_at_in(s, p, body).ok().flatten()?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return None;
|
||||
@@ -52,12 +57,20 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
|
||||
Some(ns.iter().all(|n| n.dot(n0) > 1.0 - 1e-6))
|
||||
}
|
||||
|
||||
/// The body face under a point: (mesh, face index, distance).
|
||||
/// The body face under a point: (mesh, face index, distance, body).
|
||||
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64, String)> {
|
||||
face_at_in(s, p, None).ok().flatten()
|
||||
}
|
||||
|
||||
type FacePick = (std::sync::Arc<Mesh>, u32, f64, String);
|
||||
|
||||
/// The face under a point of `only` (any body when `None`). Where faces of several bodies are
|
||||
/// equally near (bodies touching flush), the pick is ambiguous: `Err` names those bodies.
|
||||
fn face_at_in(s: &Session, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
|
||||
let st = s.model.state();
|
||||
let mut best: Option<(std::sync::Arc<Mesh>, u32, f64, String)> = None;
|
||||
let mut ambiguous = false;
|
||||
for b in &st.bodies {
|
||||
let mut best: Option<FacePick> = None;
|
||||
let mut tied: Vec<String> = Vec::new();
|
||||
for b in st.bodies.iter().filter(|b| only.is_none_or(|n| b.name == n)) {
|
||||
let m = b.mesh();
|
||||
let mut near: Option<(u32, f64)> = None;
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
@@ -70,13 +83,16 @@ fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64, Stri
|
||||
if let Some((f, d)) = near {
|
||||
if best.as_ref().is_none_or(|x| d < x.2 - 1e-8) {
|
||||
best = Some((m.clone(), f, d, b.name.clone()));
|
||||
ambiguous = false;
|
||||
tied.clear();
|
||||
} else if best.as_ref().is_some_and(|x| (d - x.2).abs() <= 1e-8) {
|
||||
ambiguous = true;
|
||||
tied.push(b.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if ambiguous { None } else { best }
|
||||
match best {
|
||||
Some(b) if !tied.is_empty() => Err(std::iter::once(b.3).chain(tied).collect()),
|
||||
b => Ok(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Circle through three points.
|
||||
@@ -97,11 +113,17 @@ fn xy(v: Vec2) -> Value {
|
||||
|
||||
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
|
||||
/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active.
|
||||
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<(u64, Vec2)> {
|
||||
let (m, f, d, body) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?;
|
||||
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2)> {
|
||||
let none = || match face_body {
|
||||
Some(b) => bad(cmd, format!("no face of `{b}` at `face`")),
|
||||
None => bad(cmd, "no body face at `face`"),
|
||||
};
|
||||
let (m, f, d, body) = face_at_in(s, point, face_body)
|
||||
.map_err(|names| bad(cmd, format!("faces of several bodies meet at `face` ({}): name one with `face_body`", names.join(", "))))?
|
||||
.ok_or_else(none)?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return Err(bad(cmd, "no body face at `face`"));
|
||||
return Err(none());
|
||||
}
|
||||
let tris: Vec<[Vec3; 3]> = m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf == f).filter_map(|(t, _)| m.tri(t)).collect();
|
||||
let normal = tris.iter().find_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).ok_or_else(|| bad(cmd, "degenerate face"))?;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
|
||||
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, expr::Kind, point_expr};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::{CommandSpec, not_in_sketch};
|
||||
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec2, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
@@ -13,11 +13,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
@@ -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; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; 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|taper (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; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, 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,14 +107,14 @@ 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").enabled(not_in_sketch).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
|
||||
/// face, and the unit normal.
|
||||
fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
|
||||
if let Some(fp) = p.get("face").and_then(vec3) {
|
||||
return Some((fp, super::face::face_normal(s, fp)?));
|
||||
return Some((fp, super::face::face_normal_in(s, fp, str_(p, "face_body"))?));
|
||||
}
|
||||
let sid = feature_sketch(s, p, "Extrude").ok()?;
|
||||
let st = s.model.state();
|
||||
@@ -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,8 +389,14 @@ 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)),
|
||||
// A conversion's bend edges are edges of the body it converts.
|
||||
FeatureKind::SheetConvert { body, bends, .. } if !bends.is_empty() => {
|
||||
(Vec::new(), solidcraft_doc::sheet::bend_edge_names(&s.model.state(), body, bends))
|
||||
}
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
@@ -470,8 +478,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> {
|
||||
@@ -479,11 +499,31 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
|
||||
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
|
||||
Some(fp) => {
|
||||
let (id, inside) = super::face::sketch_of_face(s, fp, cmd)?;
|
||||
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }))
|
||||
}
|
||||
None => (feature_sketch(s, p, cmd)?, None),
|
||||
};
|
||||
let extent = extent_param(s, p, cmd)?;
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: match face_profile {
|
||||
Some(f) => f,
|
||||
None => profiles(p, cmd)?,
|
||||
},
|
||||
extent,
|
||||
operation: match str_(p, "operation") {
|
||||
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||
_ => operation(p, cmd)?,
|
||||
},
|
||||
targets: string_list(p, "targets"),
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
/// An extrude's extent from its parameters: distance (or through all, or to an object), the
|
||||
/// side(s), a second distance, a start offset, a taper, and from an object.
|
||||
pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent> {
|
||||
let through_all = bool_(p, "through_all").unwrap_or(false) || str_(p, "extent").is_some_and(|e| e.eq_ignore_ascii_case("through_all"));
|
||||
let to_object = p.get("to").is_some();
|
||||
if to_object && p.get("to").and_then(vec3).is_none() {
|
||||
@@ -512,30 +552,17 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(d) = &start_offset {
|
||||
check_expr(s, d, Kind::Length, cmd, "start_offset")?;
|
||||
}
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: match face_profile {
|
||||
Some(f) => f,
|
||||
None => profiles(p, cmd)?,
|
||||
},
|
||||
extent: Extent {
|
||||
distance,
|
||||
direction,
|
||||
distance2,
|
||||
start_offset,
|
||||
through_all,
|
||||
taper,
|
||||
to: p.get("to").and_then(vec3),
|
||||
to_offset: expr(p, "to_offset"),
|
||||
from: p.get("from").and_then(vec3),
|
||||
},
|
||||
operation: match str_(p, "operation") {
|
||||
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||
_ => operation(p, cmd)?,
|
||||
},
|
||||
targets: string_list(p, "targets"),
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
Ok(Extent {
|
||||
distance,
|
||||
direction,
|
||||
distance2,
|
||||
start_offset,
|
||||
through_all,
|
||||
taper,
|
||||
to: p.get("to").and_then(vec3),
|
||||
to_offset: expr(p, "to_offset"),
|
||||
from: p.get("from").and_then(vec3),
|
||||
})
|
||||
}
|
||||
|
||||
fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -543,7 +570,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A planar body face revolves like a profile (its edges projected into a sketch on it).
|
||||
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
|
||||
Some(fp) => {
|
||||
let (id, inside) = super::face::sketch_of_face(s, fp, cmd)?;
|
||||
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
s.active_sketch = None;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }))
|
||||
}
|
||||
@@ -596,14 +623,36 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
fn radius_expr(p: &Value, cmd: &str) -> Result<String> {
|
||||
fn radius_expr(s: &Session, p: &Value, cmd: &str) -> Result<String> {
|
||||
match (expr(p, "radius"), expr(p, "diameter")) {
|
||||
(Some(r), _) => Ok(r),
|
||||
(None, Some(d)) => Ok(format!("({d}) / 2")),
|
||||
(None, Some(d)) => {
|
||||
let [d] = length_operands(s, [(&d, &typed_length_unit(s))]);
|
||||
Ok(format!("({d}) / 2"))
|
||||
}
|
||||
_ => Err(bad(cmd, "needs `radius` or `diameter`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit a bare length typed into a command means: the design's units. (A point's bare
|
||||
/// coordinates, and a bare number already stored in a feature, are millimetres.)
|
||||
pub(super) fn typed_length_unit(s: &Session) -> String {
|
||||
solidcraft_doc::expr::length_unit_or_mm(&s.doc.units).to_string()
|
||||
}
|
||||
|
||||
/// The lengths a command works one expression out from (a radius from a diameter, a pitch from
|
||||
/// a height), each with the unit a bare number means where it came from. Bare numbers get that
|
||||
/// unit written in, so the expression keeps the meaning they had: inside a larger expression a
|
||||
/// bare number is read in the design's units of the moment, and changing them would resize the
|
||||
/// part (#716). Next to a unit-less expression that is not a bare number (`2 * 5`, a unit-less
|
||||
/// parameter), which is read in the design's units itself, they stay bare and are read with it,
|
||||
/// as before.
|
||||
pub(super) fn length_operands<const N: usize>(s: &Session, operands: [(&str, &str); N]) -> [String; N] {
|
||||
use solidcraft_doc::expr::is_bare_number;
|
||||
let follows = operands.iter().any(|(e, _)| !is_bare_number(e) && s.doc.eval(e, Kind::Unitless).is_ok());
|
||||
operands.map(|(e, unit)| if !follows && is_bare_number(e) { format!("{} {unit}", e.trim()) } else { e.to_string() })
|
||||
}
|
||||
|
||||
/// A placement point: `[x, y, z]` (or `{x, y, z}`) of numbers (mm) or length expressions;
|
||||
/// the origin when it is not given. Anything else is refused (never read as the origin).
|
||||
pub(super) fn point_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<PointExpr>> {
|
||||
@@ -648,11 +697,17 @@ fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let corner = match (point_param(s, p, "corner", cmd)?, point_param(s, p, "center", cmd)?) {
|
||||
(Some(c), _) => c,
|
||||
// The corner half a size back from the centre (numbers stay numbers).
|
||||
// The corner half a size back from the centre (numbers stay numbers, in mm). The centre's
|
||||
// bare numbers are millimetres, the sizes' the design's units.
|
||||
(None, Some([cx, cy, cz])) => {
|
||||
let unit = typed_length_unit(s);
|
||||
let mm_per_unit = solidcraft_doc::expr::unit_info(&unit).map_or(1.0, |(_, k)| k);
|
||||
let back = |c: String, size: &String| match (c.parse::<f64>(), size.parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) => format!("{}", a - b / 2.0),
|
||||
_ => format!("({c}) - ({size}) / 2"),
|
||||
(Ok(a), Ok(b)) => format!("{}", a - b * mm_per_unit / 2.0),
|
||||
_ => {
|
||||
let [c, size] = length_operands(s, [(&c, "mm"), (size, &unit)]);
|
||||
format!("({c}) - ({size}) / 2")
|
||||
}
|
||||
};
|
||||
[back(cx, &l), back(cy, &w), back(cz, &h)]
|
||||
}
|
||||
@@ -663,7 +718,7 @@ fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.cylinder";
|
||||
let radius = radius_expr(p, cmd)?;
|
||||
let radius = radius_expr(s, p, cmd)?;
|
||||
let height = req_expr(cmd, p, "height")?;
|
||||
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
|
||||
check_expr(s, &height, Kind::Length, cmd, "height")?;
|
||||
@@ -674,7 +729,7 @@ fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.sphere";
|
||||
let radius = radius_expr(p, cmd)?;
|
||||
let radius = radius_expr(s, p, cmd)?;
|
||||
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
|
||||
let center = point_param(s, p, "center", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
|
||||
add_feature(s, p, FeatureKind::Sphere { center, radius, operation: operation(p, cmd)? })
|
||||
@@ -738,7 +793,9 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
FeatureKind::Chamfer { distance, .. } => ("distance", distance.clone()),
|
||||
_ => return Err(bad(cmd, "that face is not a fillet's or a chamfer's")),
|
||||
};
|
||||
let new = format!("({old}) + ({distance})");
|
||||
// A bare number stored in the feature is millimetres; the distance's, the design's units.
|
||||
let [old, more] = length_operands(s, [(&old, "mm"), (&distance, &typed_length_unit(s))]);
|
||||
let new = format!("({old}) + ({more})");
|
||||
let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?;
|
||||
let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?;
|
||||
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")}));
|
||||
@@ -747,7 +804,13 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
|
||||
return (r.run)(s, &json!({"faces": [face], "distance": distance}));
|
||||
}
|
||||
let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (format!("-({distance})"), "negative", "cut") };
|
||||
// Pushed in: the extrude cuts the other way by as much (a bare number stays one, so it keeps
|
||||
// being read as a bare number typed into the extrude).
|
||||
let flipped = match distance.trim().parse::<f64>() {
|
||||
Ok(v) if solidcraft_doc::expr::is_bare_number(&distance) => format!("{}", -v),
|
||||
_ => format!("-({distance})"),
|
||||
};
|
||||
let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (flipped, "negative", "cut") };
|
||||
extrude(s, &json!({"face": face, "distance": dist, "direction": dir, "operation": op}))
|
||||
}
|
||||
|
||||
@@ -783,22 +846,52 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let smooth = p.get("smooth").and_then(Value::as_bool).unwrap_or(false);
|
||||
solidcraft_doc::FilletStyle::Variable { radius2, start, mid, smooth }
|
||||
}
|
||||
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord or variable)"))),
|
||||
Some("spline") => {
|
||||
let start = p
|
||||
.get("start")
|
||||
.and_then(vec3)
|
||||
.ok_or_else(|| bad(cmd, "a spline fillet needs `start`: [x,y,z] near the end its positions start from"))?;
|
||||
let knots: Vec<f64> = p.get("knots").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_f64).collect();
|
||||
let mut radii = Vec::new();
|
||||
for r in p.get("radii").and_then(Value::as_array).into_iter().flatten().take(100) {
|
||||
let r = match r {
|
||||
Value::String(x) => x.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
_ => return Err(bad(cmd, "a control radius is a number or an expression")),
|
||||
};
|
||||
check_expr(s, &r, Kind::Length, cmd, "control radius")?;
|
||||
radii.push(r);
|
||||
}
|
||||
// All control radii (`radius` and `radii`) plus the degree (1…3) plus one.
|
||||
if radii.is_empty() || !(radii.len() + 3..=radii.len() + 5).contains(&knots.len()) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"a spline fillet needs `radii` (the control radii after `radius`) and `knots` (as many as all control radii, the degree 1…3 and one more)",
|
||||
));
|
||||
}
|
||||
solidcraft_doc::FilletStyle::Spline { start, knots, radii }
|
||||
}
|
||||
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord, variable or spline)"))),
|
||||
};
|
||||
let fit = match str_(p, "fit").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("exact") => solidcraft_doc::FilletFit::Exact,
|
||||
Some("reduce") => solidcraft_doc::FilletFit::Reduce,
|
||||
Some(o) => return Err(bad(cmd, format!("unknown fit `{o}` (exact or reduce)"))),
|
||||
Some("taper") => solidcraft_doc::FilletFit::Taper,
|
||||
Some(o) => return Err(bad(cmd, format!("unknown fit `{o}` (exact, reduce or taper)"))),
|
||||
};
|
||||
if fit == solidcraft_doc::FilletFit::Reduce && !style.is_constant() {
|
||||
return Err(bad(cmd, "`fit: reduce` takes a constant radius"));
|
||||
if !fit.is_exact() && !style.is_constant() {
|
||||
return Err(bad(cmd, "`fit: reduce` and `fit: taper` take 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 +966,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 +1399,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 +1653,18 @@ 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;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "move_in_sketch_tests.rs"]
|
||||
mod move_in_sketch_tests;
|
||||
|
||||
@@ -6,7 +6,9 @@ use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, axis_dir_param, check_expr, feature_sketch, plane_param, point_param, profiles};
|
||||
use super::features::{
|
||||
add_feature, axis_dir_param, check_expr, feature_sketch, length_operands, plane_param, point_param, profiles, typed_length_unit,
|
||||
};
|
||||
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -49,10 +51,18 @@ fn coil(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
|
||||
let turns = expr(p, "revolutions").or_else(|| expr(p, "turns"));
|
||||
let (height, pitch) = (expr(p, "height"), expr(p, "pitch"));
|
||||
// The pitch or the revolutions from the height keep the units the lengths were given in.
|
||||
let unit = typed_length_unit(s);
|
||||
let (turns, pitch) = match (turns, height, pitch) {
|
||||
(Some(n), _, Some(pt)) => (n, pt),
|
||||
(Some(n), Some(h), None) => (n.clone(), format!("({h}) / ({n})")),
|
||||
(None, Some(h), Some(pt)) => (format!("({h}) / ({pt})"), pt),
|
||||
(Some(n), Some(h), None) => {
|
||||
let [h] = length_operands(s, [(&h, &unit)]);
|
||||
(n.clone(), format!("({h}) / ({n})"))
|
||||
}
|
||||
(None, Some(h), Some(pt)) => {
|
||||
let [h, by] = length_operands(s, [(&h, &unit), (&pt, &unit)]);
|
||||
(format!("({h}) / ({by})"), pt)
|
||||
}
|
||||
_ => return Err(bad(cmd, "give two of `revolutions`, `height` and `pitch`")),
|
||||
};
|
||||
check_expr(s, &turns, Kind::Unitless, cmd, "revolutions")?;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+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,329 @@
|
||||
//! 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}");
|
||||
}
|
||||
|
||||
/// Offset Face raises one piece of a split top: the pieces no longer share a plane, so each
|
||||
/// takes a sketch by its own name (the face's, qualified by the faces around it) and follows it.
|
||||
#[test]
|
||||
fn a_raised_piece_of_a_split_top_takes_a_sketch_by_its_own_name() {
|
||||
let mut s = slotted_block();
|
||||
run(&mut s, "parameters.add", json!({"name": "raise", "expression": "2 mm"}));
|
||||
run(&mut s, "solid.offset_face", json!({"faces": [[5, 15, 10]], "distance": "raise", "name": "Raise"}));
|
||||
no_errors(&s);
|
||||
sketch_on(&mut s, [5., 15., 12.], "Raised");
|
||||
sketch_on(&mut s, [35., 15., 10.], "Kept");
|
||||
let (raised, certificate) = healthy(&s, "Raised");
|
||||
assert_eq!(certificate, Some(FaceCertificate::Lineage));
|
||||
assert!((raised.origin.z - 12.).abs() < 1e-9 && (raised.normal().z - 1.).abs() < 1e-9, "{raised:?}");
|
||||
assert!((healthy(&s, "Kept").0.origin.z - 10.).abs() < 1e-9);
|
||||
run(&mut s, "parameters.change", json!({"name": "raise", "expression": "3 mm"}));
|
||||
no_errors(&s);
|
||||
assert!((healthy(&s, "Raised").0.origin.z - 13.).abs() < 1e-9);
|
||||
assert!((healthy(&s, "Kept").0.origin.z - 10.).abs() < 1e-9);
|
||||
let t = reopened(&s);
|
||||
assert!((healthy(&t, "Raised").0.origin.z - 13.).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// A plastic Boss keeps the history of the body it stands on (#494): the plate's faces still
|
||||
/// take attached sketches, and so does the boss's own top, which follows its height.
|
||||
#[test]
|
||||
fn faces_after_a_boss_take_a_sketch_that_follows_it() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "boss_h", "expression": "12 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 2, "name": "Plate"}));
|
||||
run(
|
||||
&mut s,
|
||||
"plastic.boss",
|
||||
json!({"position": [20, 20, 2], "diameter": 7, "height": "boss_h", "hole_diameter": 2.5, "fillet": 0.5, "name": "Boss"}),
|
||||
);
|
||||
no_errors(&s);
|
||||
sketch_on(&mut s, [5., 25., 2.], "OnPlate");
|
||||
sketch_on(&mut s, [80., 25., 1.], "OnSide");
|
||||
sketch_on(&mut s, [22.5, 20., 14.], "OnBoss");
|
||||
for name in ["OnPlate", "OnSide"] {
|
||||
healthy(&s, name);
|
||||
}
|
||||
let (top, certificate) = healthy(&s, "OnBoss");
|
||||
assert_eq!(certificate, Some(FaceCertificate::Lineage));
|
||||
assert!((top.origin.z - 14.).abs() < 1e-9, "{top:?}");
|
||||
run(&mut s, "parameters.change", json!({"name": "boss_h", "expression": "15 mm"}));
|
||||
no_errors(&s);
|
||||
assert!((healthy(&s, "OnBoss").0.origin.z - 17.).abs() < 1e-9);
|
||||
healthy(&reopened(&s), "OnBoss");
|
||||
}
|
||||
|
||||
/// A Hole names its faces by the part they cut (a counterbore's floor is `F<hole>:cbore:base`),
|
||||
/// and a pattern's copies of it as copies: each floor takes its own sketch, which follows the
|
||||
/// counterbore's depth. The plate's faces stay attachable too.
|
||||
#[test]
|
||||
fn counterbore_floors_and_their_pattern_copies_take_a_sketch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "cb", "expression": "3 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 80, "width": 30, "height": 10, "name": "Plate"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.hole",
|
||||
json!({"position": [15, 15, 10], "diameter": 5, "type": "counterbore", "cb_diameter": 10, "cb_depth": "cb", "name": "Hole"}),
|
||||
);
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Hole"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 25, "name": "Row"}));
|
||||
no_errors(&s);
|
||||
for (name, x) in [("Seed", 18.5), ("Copy", 43.5), ("Top", 30.)] {
|
||||
sketch_on(&mut s, [x, 15., if name == "Top" { 10. } else { 7. }], name);
|
||||
}
|
||||
for name in ["Seed", "Copy"] {
|
||||
let (floor, certificate) = healthy(&s, name);
|
||||
assert_eq!(certificate, Some(FaceCertificate::Lineage), "{name}");
|
||||
assert!((floor.origin.z - 7.).abs() < 1e-9, "{name}: {floor:?}");
|
||||
}
|
||||
healthy(&s, "Top");
|
||||
run(&mut s, "parameters.change", json!({"name": "cb", "expression": "4 mm"}));
|
||||
no_errors(&s);
|
||||
for name in ["Seed", "Copy"] {
|
||||
assert!((healthy(&s, name).0.origin.z - 6.).abs() < 1e-9, "{name}");
|
||||
}
|
||||
healthy(&reopened(&s), "Copy");
|
||||
}
|
||||
|
||||
/// The middle of the largest triangle of each flat face of the first body.
|
||||
fn flat_faces(s: &Session) -> Vec<(usize, solidcraft_geom::Vec3)> {
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
let mesh = body.tessellate(0.01).unwrap();
|
||||
(0..body.face_count())
|
||||
.filter(|index| body.planar_face(*index).is_some())
|
||||
.filter_map(|index| {
|
||||
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))?;
|
||||
Some((index, point))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every flat face of the first body takes an attached sketch (#681: a flat face is a flat face,
|
||||
/// whichever features it was merged from).
|
||||
fn every_flat_face_takes_a_sketch(s: &mut Session) {
|
||||
no_errors(s);
|
||||
let faces = flat_faces(s);
|
||||
assert!(faces.len() >= 8, "{}", faces.len());
|
||||
let mut failed = Vec::new();
|
||||
for (index, point) in faces {
|
||||
match s.execute("sketch.create", &json!({"plane": {"face": point, "body": "Body1", "index": index}})) {
|
||||
Ok(made) => {
|
||||
run(s, "sketch.finish", json!({}));
|
||||
let id = made["sketch"].as_u64().unwrap();
|
||||
if !matches!(s.placed_document().sketch_face_attachment(id, &s.model), FaceAttachmentStatus::Healthy { .. }) {
|
||||
failed.push(format!("face {index} at {point:?}: not healthy"));
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(format!("face {index} at {point:?}: {e}")),
|
||||
}
|
||||
}
|
||||
assert!(failed.is_empty(), "{}", failed.join("\n"));
|
||||
}
|
||||
|
||||
/// #681: a block with a lower step joined flush beside it. The step's side walls continue the
|
||||
/// block's (a Join merged them), and they take a sketch like every other flat face.
|
||||
#[test]
|
||||
fn every_flat_face_of_a_block_and_a_flush_step_takes_a_sketch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "A"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "A", "distance": 10, "name": "Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 10], "p1": [20, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": 5, "operation": "join", "name": "Step"}));
|
||||
every_flat_face_takes_a_sketch(&mut s);
|
||||
}
|
||||
|
||||
/// #681 as reported: one sketch of a 2 x 2 grid, three cells extruded up (an elbow) and three
|
||||
/// joined down (the inverse elbow).
|
||||
#[test]
|
||||
#[ignore = "the join of the elbow down fails until #679 is fixed"]
|
||||
fn every_flat_face_of_an_elbow_up_and_an_elbow_down_takes_a_sketch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Grid"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0], [20, 20], [0, 20]], "closed": true}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 0], [10, 20]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 10], [20, 10]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let cells = |pts: [[f64; 2]; 3]| pts.iter().map(|p| json!({"point": p})).collect::<Vec<_>>();
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Grid", "distance": 10, "profiles": cells([[5., 5.], [15., 5.], [5., 15.]]), "name": "Up"}));
|
||||
let down = cells([[15., 5.], [5., 15.], [15., 15.]]);
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Grid", "distance": 10, "direction": "negative", "operation": "join", "profiles": down, "name": "Down"}),
|
||||
);
|
||||
every_flat_face_takes_a_sketch(&mut s);
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -9,6 +9,17 @@ macro_rules! created_doc {
|
||||
};
|
||||
}
|
||||
|
||||
/// A command that is designed and registered (id, toolbar place, icon, params doc) but not built
|
||||
/// yet. It is disabled with the reason, so the toolbar greys it and says why, and its `run`
|
||||
/// refuses too, however it is reached. `$issue` is the GitHub issue that tracks building it.
|
||||
macro_rules! planned {
|
||||
($id:literal, $label:literal, $issue:literal) => {
|
||||
CommandSpec::new($id, $label, |_, _| Err(crate::EngineError::Other(concat!("not supported yet: ", $label, " (issue #", $issue, ")").into())))
|
||||
.enabled(|_| Err(concat!("not supported yet (issue #", $issue, ")").into()))
|
||||
.planned($issue)
|
||||
};
|
||||
}
|
||||
|
||||
mod browser;
|
||||
mod clipboard;
|
||||
mod component;
|
||||
@@ -22,10 +33,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;
|
||||
@@ -36,6 +49,7 @@ pub(crate) use measure_sel::measure_items;
|
||||
mod kernel_heal_tests;
|
||||
mod parameters;
|
||||
mod plastic;
|
||||
mod render;
|
||||
mod sheet;
|
||||
mod sketch;
|
||||
pub(crate) use sketch::author_path_for_rebind;
|
||||
@@ -51,6 +65,7 @@ mod sketch_modify;
|
||||
mod sketch_project;
|
||||
mod surface;
|
||||
mod surface_analysis;
|
||||
mod view_navigation;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
@@ -85,6 +100,8 @@ pub struct CommandSpec {
|
||||
/// Reads the evaluated model (the default). A command that reads only the document and the
|
||||
/// session's history runs on a model left stale by a live gesture without evaluating it first.
|
||||
pub reads_model: bool,
|
||||
/// Registered but not built yet (see `planned!`): the GitHub issue number that tracks it.
|
||||
pub planned: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
@@ -102,8 +119,14 @@ impl CommandSpec {
|
||||
undoable: true,
|
||||
live: None,
|
||||
reads_model: true,
|
||||
planned: None,
|
||||
}
|
||||
}
|
||||
/// Not built yet; `issue` tracks building it.
|
||||
pub const fn planned(mut self, issue: &'static str) -> Self {
|
||||
self.planned = Some(issue);
|
||||
self
|
||||
}
|
||||
/// Takes `live: true`, run by `run`.
|
||||
pub const fn live(mut self, run: LiveRun) -> Self {
|
||||
self.live = Some(run);
|
||||
@@ -157,6 +180,7 @@ impl CommandSpec {
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
disabled_reason: e.err(),
|
||||
planned: self.planned,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,6 +196,8 @@ pub struct CommandInfo {
|
||||
pub params: &'static str,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub planned: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
@@ -182,6 +208,12 @@ pub fn in_sketch(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active_sketch.is_some() { Ok(()) } else { Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()) }
|
||||
}
|
||||
|
||||
/// The mirror of [`in_sketch`], for a command that acts on bodies and has nothing to offer the
|
||||
/// geometry under the cursor while a sketch is open.
|
||||
pub fn not_in_sketch(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active_sketch.is_none() { Ok(()) } else { Err("a sketch is being edited (this works on bodies: Finish Sketch first)".into()) }
|
||||
}
|
||||
|
||||
/// Every command, in toolbar order.
|
||||
pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
let mut v: Vec<&'static CommandSpec> = Vec::new();
|
||||
@@ -203,6 +235,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,8 +247,11 @@ 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(view_navigation::COMMANDS.iter());
|
||||
v.extend(diagnostics::COMMANDS.iter());
|
||||
v.extend(render::COMMANDS.iter());
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! `solid.move` moves bodies, so it has nothing to offer the geometry under the cursor while a
|
||||
//! sketch is being edited. It is disabled there, with a reason, and comes back once the sketch
|
||||
//! is finished.
|
||||
use super::*;
|
||||
|
||||
fn cube(s: &mut Session) {
|
||||
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20})).unwrap();
|
||||
}
|
||||
|
||||
fn spec() -> &'static crate::cmd::CommandSpec {
|
||||
crate::find_command("solid.move").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_is_disabled_while_a_sketch_is_being_edited_and_says_why() {
|
||||
let mut s = Session::default();
|
||||
cube(&mut s);
|
||||
assert!(spec().info(&s).enabled, "Move is enabled with no sketch open");
|
||||
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
let info = spec().info(&s);
|
||||
assert!(!info.enabled, "Move must be disabled while a sketch is being edited");
|
||||
let why = info.disabled_reason.unwrap_or_default();
|
||||
assert!(why.contains("sketch is being edited"), "the reason must name the sketch: {why}");
|
||||
assert!(why.contains("bodies"), "the reason must say what Move acts on: {why}");
|
||||
assert!(why.contains("Finish Sketch"), "the reason must say the way out: {why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_move_in_a_sketch_is_refused_and_changes_nothing() {
|
||||
let mut s = Session::default();
|
||||
cube(&mut s);
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
let features = s.doc.features.len();
|
||||
|
||||
let e = s.execute("solid.move", &json!({"bodies": ["Body1"], "translate": [30, 0, 0]})).unwrap_err();
|
||||
assert!(matches!(e, EngineError::Disabled(ref id, _) if id == "solid.move"), "{e:?}");
|
||||
|
||||
assert_eq!(s.doc.features.len(), features, "a refused command adds no feature");
|
||||
assert!(s.active_sketch.is_some(), "the sketch is still being edited");
|
||||
let bounds = s.model.state().body("Body1").unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(Vec3::ZERO) < 1e-6, "the body did not move: {bounds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_works_again_once_the_sketch_is_finished() {
|
||||
let mut s = Session::default();
|
||||
cube(&mut s);
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
assert!(spec().info(&s).enabled, "Move comes back once the sketch is finished");
|
||||
|
||||
s.execute("solid.move", &json!({"bodies": ["Body1"], "translate": [30, 0, 0]})).unwrap();
|
||||
let bounds = s.model.state().body("Body1").unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(Vec3::new(30.0, 0.0, 0.0)) < 1e-6, "{bounds:?}");
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -241,16 +260,53 @@ fn hole_pattern(count1: usize, count2: usize) -> (f64, std::time::Duration) {
|
||||
(v, took)
|
||||
}
|
||||
|
||||
/// Whether a job four times the size took at most about twice linear: `pair` times the small job,
|
||||
/// then the big one. Linear is 4×, the old quadratic costs 16× or more. The load of the other test
|
||||
/// binaries can rise between the two runs (on a 16-core Mac under `cargo xtask test`, 32 circles
|
||||
/// took 4.7 s and 128 took 42 s, though both together take 18 s alone), so a pair that misses is
|
||||
/// measured again, up to three times. Load only ever slows a run, so each size keeps its fastest
|
||||
/// time and the ratio is taken of those: a retry cannot pass a regression on a small run that a
|
||||
/// spike happened to slow, once an earlier small run was fast. (A spike on the very first small
|
||||
/// run can still hide one, as it could before the retries.)
|
||||
fn scales_linearly(mut pair: impl FnMut() -> (std::time::Duration, std::time::Duration)) -> Result<(), String> {
|
||||
let (mut small, mut big) = (f64::INFINITY, f64::INFINITY);
|
||||
let mut runs = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let (s, b) = pair();
|
||||
small = small.min(s.as_secs_f64());
|
||||
big = big.min(b.as_secs_f64());
|
||||
runs.push(format!("{s:?} then {b:?}"));
|
||||
if big < 8.0 * small + 2.0 {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(format!("{}; fastest {small:.2} s and {big:.2} s, limit {:.2} s", runs.join(", "), 8.0 * small + 2.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scales_linearly_keeps_each_sizes_fastest_run() {
|
||||
use std::time::Duration;
|
||||
let pairs = |p: Vec<(u64, u64)>| {
|
||||
let mut p = p.into_iter().map(|(s, b)| (Duration::from_secs(s), Duration::from_secs(b)));
|
||||
move || p.next().unwrap_or((Duration::from_secs(1000), Duration::from_secs(1000)))
|
||||
};
|
||||
assert!(scales_linearly(pairs(vec![(1, 4)])).is_ok());
|
||||
// A big run slowed by load, then a quiet pair.
|
||||
assert!(scales_linearly(pairs(vec![(1, 30), (1, 5)])).is_ok());
|
||||
// A quadratic cost (16×) whose later small runs a spike slowed: the fast first one still counts.
|
||||
let r = scales_linearly(pairs(vec![(1, 16), (3, 16), (3, 16)]));
|
||||
assert!(r.as_ref().is_err_and(|e| e.contains("fastest 1.00 s and 16.00 s, limit 10.00 s")), "{r:?}");
|
||||
}
|
||||
|
||||
/// 512 identical through-holes from a feature pattern of one cut (#261). 128 took 30 s and 512 did
|
||||
/// not finish in 150 s: each copy was cut into an ever more complex plate. The time must grow about
|
||||
/// linearly with the count. A ratio, not a time: CI runs every test binary at once and is many
|
||||
/// times slower than a machine running this alone (about 6 s for 512 holes there).
|
||||
#[test]
|
||||
fn pattern_of_512_hole_cuts_finishes_in_reasonable_time() {
|
||||
let (_, t128) = hole_pattern(16, 8);
|
||||
let (_, t512) = hole_pattern(32, 16);
|
||||
// Linear is 4×, the old quadratic cost 16× or more.
|
||||
assert!(t512.as_secs_f64() < 8.0 * t128.as_secs_f64() + 2.0, "128 holes took {t128:?}, 512 took {t512:?}");
|
||||
if let Err(missed) = scales_linearly(|| (hole_pattern(16, 8).1, hole_pattern(32, 16).1)) {
|
||||
panic!("128 holes, then 512, took {missed}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A plate with `n` Ø2 circles drawn in one sketch (16 a row, 6 mm apart) and cut by one extrude:
|
||||
@@ -286,7 +342,7 @@ fn sketch_hole_cut(n: usize) -> std::time::Duration {
|
||||
/// linear since #261. Linear is 4×. A ratio, not a time (see the 512-hole pattern test).
|
||||
#[test]
|
||||
fn one_extrude_of_many_sketch_circles_scales_linearly() {
|
||||
let t32 = sketch_hole_cut(32);
|
||||
let t128 = sketch_hole_cut(128);
|
||||
assert!(t128.as_secs_f64() < 8.0 * t32.as_secs_f64() + 2.0, "32 circles took {t32:?}, 128 took {t128:?}");
|
||||
if let Err(missed) = scales_linearly(|| (sketch_hole_cut(32), sketch_hole_cut(128))) {
|
||||
panic!("32 circles, then 128, took {missed}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Render capability discovery. No rendering, host probing or document mutation occurs here.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("render.backends", "Render Backends", backends)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("No parameters. Returns {schema_version: 1, default_backend: builtin, backends: [{id, label, availability, executors: [{id, availability, profiles, limits, scratch}]}]}. Metadata only; does not start rendering or probe a GPU. See docs/render-backends.md")];
|
||||
|
||||
fn backends(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
if !params.as_object().is_some_and(|p| p.is_empty()) {
|
||||
return Err(bad("render.backends", "expected an empty parameter object"));
|
||||
}
|
||||
Ok(json!({"schema_version": 1, "default_backend": "builtin", "backends": [crate::render::builtin_backend()]}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn assert_unchanged(s: &mut Session, params: Value) -> Result<Value> {
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale());
|
||||
let camera = s.camera;
|
||||
let undo = s.undo.clone();
|
||||
let redo = s.redo.clone();
|
||||
let result = s.execute("render.backends", ¶ms);
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.is_dirty(), s.revision, s.evaluations, s.model.is_stale()));
|
||||
assert_eq!(camera, s.camera);
|
||||
for (before, after) in [(&undo, &s.undo), (&redo, &s.redo)] {
|
||||
assert_eq!(before.len(), after.len());
|
||||
for (a, b) in before.iter().zip(after.iter()) {
|
||||
assert!(Arc::ptr_eq(&a.doc, &b.doc));
|
||||
assert_eq!((&a.label, a.active_sketch, &a.pending_moves), (&b.label, b.active_sketch, &b.pending_moves));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_preserves_clean_dirty_and_undo_redo_state() {
|
||||
let mut s = Session::default();
|
||||
let r = assert_unchanged(&mut s, Value::Null).unwrap();
|
||||
assert_eq!(r["schema_version"], 1);
|
||||
assert_eq!(r["default_backend"], "builtin");
|
||||
assert_eq!(r["backends"].as_array().unwrap().len(), 1);
|
||||
let descriptor: crate::render::BackendDescriptor = serde_json::from_value(r["backends"][0].clone()).unwrap();
|
||||
assert_eq!(descriptor, crate::render::builtin_backend());
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 20, "height": 5})).unwrap();
|
||||
s.execute("view.apply", &json!({"view": "top"})).unwrap();
|
||||
assert!(s.is_dirty());
|
||||
assert_unchanged(&mut s, json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.redo.is_empty());
|
||||
assert_unchanged(&mut s, json!({})).unwrap();
|
||||
for p in [json!([]), json!(3), json!({"backend": "missing"}), json!({"width": "bad"})] {
|
||||
assert!(assert_unchanged(&mut s, p).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_does_not_settle_a_live_drag_or_rebuild_geometry() {
|
||||
let mut s = Session::default();
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane": "XY"})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]})),
|
||||
("sketch.dimension", json!({"entities": ["l1"], "value": 20})),
|
||||
("sketch.dimension", json!({"entities": ["l2"], "value": 15})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"distance": 5})),
|
||||
("sketch.edit", json!({"sketch": "Sketch1"})),
|
||||
("sketch.drag", json!({"entity": "l1.start", "from": [5, 5], "to": [4, 5], "live": true})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap();
|
||||
}
|
||||
assert!(s.model.is_stale());
|
||||
s.evaluations = 0;
|
||||
let model = s.model.state();
|
||||
assert_unchanged(&mut s, json!({})).unwrap();
|
||||
assert_unchanged(&mut s, json!({"unexpected": true})).unwrap_err();
|
||||
assert!(Arc::ptr_eq(&model, &s.model.state()));
|
||||
assert_eq!(s.evaluations, 0);
|
||||
}
|
||||
}
|
||||
+149
-23
@@ -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")
|
||||
@@ -32,23 +34,33 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.convert", "Convert to Sheet Metal", convert)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("convert_sheet")
|
||||
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
|
||||
.params(
|
||||
"body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?; \
|
||||
bends?: [[x,y,z] on sharp edges] (a solid or a shelled body: `face` is the fixed face and the flat faces reached from it \
|
||||
across these edges become the sheet; a shell keeps its thickness, a solid takes the rule's inward; \
|
||||
edges between two of those faces that are no bends are cut open with the rule's gap)",
|
||||
),
|
||||
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|tear (what is cut where two bends meet at a corner of the base: round and rectangular a relief \
|
||||
`corner_relief` across; tear none, each bend's end is cut straight from the corner to its flange's end; 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 +73,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 +114,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 +166,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 +242,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> {
|
||||
@@ -200,8 +278,17 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
let face = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the plate's face"))?;
|
||||
// With bend edges, a solid or shelled body; without, a plate.
|
||||
let bends = match p.get("bends") {
|
||||
None | Some(Value::Null) => Vec::new(),
|
||||
Some(_) => points(p, "bends", cmd)?,
|
||||
};
|
||||
// Such a sheet's values are its rule's (a plate's thickness is the plate's own).
|
||||
if let Some(key) = RULE_ONLY.iter().find(|k| !bends.is_empty() && p.get(**k).is_some()) {
|
||||
return Err(bad(cmd, format!("`{key}` is not a sheet.convert parameter: the sheet metal rule sets it. Pass a rule's name as `rule`")));
|
||||
}
|
||||
let rule = rule_param(s, p, cmd)?;
|
||||
add_feature(s, p, FeatureKind::SheetConvert { body, face, rule })
|
||||
add_feature(s, p, FeatureKind::SheetConvert { body, face, rule, bends })
|
||||
}
|
||||
|
||||
fn rules(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -235,6 +322,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, rectangular or tear"))?.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 +365,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 +410,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 +432,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 +511,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 {
|
||||
@@ -492,3 +614,7 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
#[path = "sheet_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sheet_convert_tests.rs"]
|
||||
mod convert_tests;
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Convert to Sheet Metal with bend edges: solid and shelled bodies.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI, SQRT_2};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn bbox(s: &mut Session) -> ([f64; 3], [f64; 3]) {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
let b = &m["bodies"][0]["bbox"];
|
||||
let v = |k: &str| [0, 1, 2].map(|i| b[k][i].as_f64().unwrap_or(f64::NAN));
|
||||
(v("min"), v("max"))
|
||||
}
|
||||
|
||||
fn flat(s: &mut Session) -> Value {
|
||||
run(s, "sheet.flat_pattern", json!({}))
|
||||
}
|
||||
|
||||
fn flat_size(s: &mut Session) -> (f64, f64) {
|
||||
let f = flat(s);
|
||||
let v = |i: usize| f["flat_size_mm"][i].as_f64().unwrap_or(f64::NAN);
|
||||
(v(0), v(1))
|
||||
}
|
||||
|
||||
/// Either way round (the flat frame's x runs along the fixed face's longest edge).
|
||||
fn same_size(got: (f64, f64), want: (f64, f64), tol: f64) -> bool {
|
||||
((got.0 - want.0).abs() < tol && (got.1 - want.1).abs() < tol) || ((got.0 - want.1).abs() < tol && (got.1 - want.0).abs() < tol)
|
||||
}
|
||||
|
||||
/// The bend table's rows as (angle in whole degrees, direction, length).
|
||||
fn bend_rows(s: &mut Session) -> Vec<(i64, String, f64)> {
|
||||
let rows = flat(s)["bends"].as_array().cloned().unwrap_or_default();
|
||||
let row = |b: &Value| {
|
||||
(
|
||||
b["angle_deg"].as_f64().unwrap_or(0.0).round() as i64,
|
||||
b["direction"].as_str().unwrap_or("").to_string(),
|
||||
b["length"].as_f64().unwrap_or(0.0),
|
||||
)
|
||||
};
|
||||
rows.iter().map(row).collect()
|
||||
}
|
||||
|
||||
fn rule(s: &mut Session, name: &str, t: f64, k: f64, r: f64, gap: f64) {
|
||||
let mm = |v: f64| format!("{v} mm");
|
||||
run(
|
||||
s,
|
||||
"sheet.manage_rules",
|
||||
json!({"name": name, "thickness": mm(t), "k_factor": format!("{k}"), "bend_radius": mm(r), "gap": mm(gap), "active": true}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Bend deduction: twice the outside setback less the allowance.
|
||||
fn deduction(theta: f64, r: f64, t: f64, k: f64) -> f64 {
|
||||
2.0 * (r + t) * (theta / 2.0).tan() - theta * (r + k * t)
|
||||
}
|
||||
|
||||
const BOTTOM_EDGES: [[f64; 3]; 4] = [[50.0, 0.0, 0.0], [100.0, 30.0, 0.0], [50.0, 60.0, 0.0], [0.0, 30.0, 0.0]];
|
||||
|
||||
/// A 100 x 60 x 30 box shelled to `t` with its top open.
|
||||
fn tray(s: &mut Session, t: f64) {
|
||||
run(s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Tray"}));
|
||||
run(s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": t}));
|
||||
}
|
||||
|
||||
/// The folded tray's volume: base, four 90° bends and four walls cut back at the corners.
|
||||
fn tray_volume(l: f64, w: f64, h: f64, t: f64, r: f64, gap: f64) -> f64 {
|
||||
let s = r + t;
|
||||
let cut = t + gap / SQRT_2;
|
||||
let base = (l - 2.0 * s) * (w - 2.0 * s);
|
||||
let bends = FRAC_PI_2 * (r + t / 2.0) * 2.0 * ((l - 2.0 * s) + (w - 2.0 * s));
|
||||
let walls = (h - s) * 2.0 * ((l - 2.0 * cut) + (w - 2.0 * cut));
|
||||
t * (base + bends + walls)
|
||||
}
|
||||
|
||||
/// A strip `t` thick along the polyline `outer` (its material to the left of the way the line
|
||||
/// runs, its ends cut square), extruded `len` along z as the body `Section`. Returns a point on
|
||||
/// the edge at each corner of the polyline.
|
||||
fn section(s: &mut Session, outer: &[(f64, f64)], t: f64, len: f64) -> Vec<[f64; 3]> {
|
||||
let n = outer.len();
|
||||
let dir = |i: usize| {
|
||||
let (a, b) = (outer[i], outer[i + 1]);
|
||||
let l = (b.0 - a.0).hypot(b.1 - a.1);
|
||||
((b.0 - a.0) / l, (b.1 - a.1) / l)
|
||||
};
|
||||
let shift = |p: (f64, f64), d: (f64, f64)| (p.0 - d.1 * t, p.1 + d.0 * t);
|
||||
let mut inner = vec![shift(outer[0], dir(0))];
|
||||
for (i, &corner) in outer.iter().enumerate().take(n - 1).skip(1) {
|
||||
let (d0, d1) = (dir(i - 1), dir(i));
|
||||
let (p0, p1) = (shift(corner, d0), shift(corner, d1));
|
||||
let along = ((p1.0 - p0.0) * d1.1 - (p1.1 - p0.1) * d1.0) / (d0.0 * d1.1 - d0.1 * d1.0);
|
||||
inner.push((p0.0 + d0.0 * along, p0.1 + d0.1 * along));
|
||||
}
|
||||
inner.push(shift(outer[n - 1], dir(n - 2)));
|
||||
let pts: Vec<[f64; 2]> = outer.iter().chain(inner.iter().rev()).map(|p| [p.0, p.1]).collect();
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Section"}));
|
||||
run(s, "sketch.line", json!({"points": pts, "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": "Section", "distance": len, "body_name": "Section"}));
|
||||
outer[1..n - 1].iter().map(|p| [p.0, p.1, len / 2.0]).collect()
|
||||
}
|
||||
|
||||
/// A base 200 wide with walls 40 long at 72° and returns 15 long at 108° more, 300 long.
|
||||
fn trapezoid_section(s: &mut Session, t: f64) -> Vec<[f64; 3]> {
|
||||
let (c, sn) = (72f64.to_radians().cos(), 72f64.to_radians().sin());
|
||||
let (x, y) = (100.0 + 40.0 * c, 40.0 * sn);
|
||||
section(s, &[(-x + 15.0, y), (-x, y), (-100.0, 0.0), (100.0, 0.0), (x, y), (x - 15.0, y)], t, 300.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shelled_tray_keeps_its_walls_and_unfolds_by_the_k_factor() {
|
||||
let (t, k, r, gap) = (2.0, 0.44, 2.0, 0.5);
|
||||
let mut s = Session::default();
|
||||
// The shell's own thickness counts, not the rule's.
|
||||
rule(&mut s, "Test", 3.0, k, r, gap);
|
||||
tray(&mut s, t);
|
||||
let out = run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
assert!(out["warning"].is_null(), "{out}");
|
||||
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - t).abs() < 1e-9);
|
||||
// Four 90° bends up, each as long as the base is between its setbacks (R + T at either end).
|
||||
let mut rows = bend_rows(&mut s);
|
||||
rows.sort_by(|a, b| a.2.total_cmp(&b.2));
|
||||
let want = [52.0, 52.0, 92.0, 92.0];
|
||||
assert!(rows.len() == 4 && rows.iter().zip(want).all(|(row, l)| row.0 == 90 && row.1 == "up" && (row.2 - l).abs() < 1e-6), "{rows:?}");
|
||||
let allowance = flat(&mut s)["bends"][0]["allowance"].as_f64().unwrap_or(0.0);
|
||||
assert!((allowance - FRAC_PI_2 * (r + k * t)).abs() < 1e-9, "{allowance}");
|
||||
// The envelope stays: the sharp edges became bends inside it.
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(
|
||||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[1] - 60.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6,
|
||||
"{lo:?} {hi:?}"
|
||||
);
|
||||
// Flat: the outside lengths less two bend deductions each way.
|
||||
let bd = deduction(FRAC_PI_2, r, t, k);
|
||||
let want = (100.0 + 60.0 - 2.0 * bd, 60.0 + 60.0 - 2.0 * bd);
|
||||
assert!(same_size(flat_size(&mut s), want, 1e-6), "{:?} vs {want:?}", flat_size(&mut s));
|
||||
let v = volume(&mut s);
|
||||
let expect = tray_volume(100.0, 60.0, 30.0, t, r, gap);
|
||||
assert!((v - expect).abs() < 2e-3 * expect, "{v} vs {expect}");
|
||||
// Unfold: flat, one sheet thick, the same outline; refold gives the tray back.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!((hi[2] - lo[2] - t).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
assert!(same_size((hi[0] - lo[0], hi[1] - lo[1]), want, 1e-6), "{lo:?} {hi:?}");
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
|
||||
// It exports like a sheet made from flanges: an outline and four bend lines up.
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}));
|
||||
assert_eq!(dxf["bends"], 4);
|
||||
let lines: Vec<&str> = dxf["dxf"].as_str().unwrap_or_default().lines().collect();
|
||||
assert_eq!(lines.windows(2).filter(|w| w[0].trim() == "8" && w[1].trim() == "BEND_UP").count(), 4, "four bend lines");
|
||||
assert_eq!(run(&mut s, "sheet.export_bend_table", json!({"format": "json"}))["bends"], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bends_picked_inside_and_a_fixed_face_inside_give_the_same_sheet() {
|
||||
let (t, k, r, gap) = (2.0, 0.44, 2.0, 0.5);
|
||||
let inner = [[50.0, 2.0, 2.0], [98.0, 30.0, 2.0], [50.0, 58.0, 2.0], [2.0, 30.0, 2.0]];
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, gap);
|
||||
tray(&mut s, t);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
let (v, f) = (volume(&mut s), flat_size(&mut s));
|
||||
// The inner edges (2 mm in and up) are the same bends.
|
||||
let mut a = Session::default();
|
||||
rule(&mut a, "Test", t, k, r, gap);
|
||||
tray(&mut a, t);
|
||||
run(&mut a, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": inner}));
|
||||
assert!((volume(&mut a) - v).abs() < 1e-6 * v && same_size(flat_size(&mut a), f, 1e-9));
|
||||
// From the floor inside, the sheet's other skin is the fixed one: the bends turn down from it
|
||||
// and the flat is the inside lengths less the inside setbacks, L' + 2H' - 4R + 2BA, which is
|
||||
// the same blank.
|
||||
let mut b = Session::default();
|
||||
rule(&mut b, "Test", t, k, r, gap);
|
||||
tray(&mut b, t);
|
||||
run(&mut b, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 2], "bends": BOTTOM_EDGES}));
|
||||
assert!(bend_rows(&mut b).iter().all(|row| row.0 == 90 && row.1 == "down"), "{:?}", bend_rows(&mut b));
|
||||
let ba = FRAC_PI_2 * (r + k * t);
|
||||
let inside = (96.0 + 2.0 * 28.0 - 4.0 * r + 2.0 * ba, 56.0 + 2.0 * 28.0 - 4.0 * r + 2.0 * ba);
|
||||
assert!(same_size(flat_size(&mut b), inside, 1e-6) && same_size(inside, f, 1e-6), "{:?} {inside:?} {f:?}", flat_size(&mut b));
|
||||
assert!((volume(&mut b) - v).abs() < 1e-6 * v, "{} vs {v}", volume(&mut b));
|
||||
let (lo, hi) = bbox(&mut b);
|
||||
assert!(lo.iter().all(|x| x.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_solid_block_takes_its_rules_thickness_inward() {
|
||||
let (k, r, gap) = (0.44, 2.0, 0.5);
|
||||
for (name, t) in [(None, 2.0), (Some("Thick"), 3.0)] {
|
||||
// The active rule, or the one the conversion names.
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Thick", 3.0, k, r, gap);
|
||||
rule(&mut s, "Test", 2.0, k, r, gap);
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Block"}));
|
||||
let mut p = json!({"body": "Block", "face": [50, 30, 0], "bends": BOTTOM_EDGES});
|
||||
if let Some(n) = name {
|
||||
p["rule"] = json!(n);
|
||||
}
|
||||
run(&mut s, "sheet.convert", p);
|
||||
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - t).abs() < 1e-9);
|
||||
// The block's faces are the sheet's outside; its top is left out.
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(
|
||||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 100.0).abs() < 1e-6 && (hi[1] - 60.0).abs() < 1e-6 && (hi[2] - 30.0).abs() < 1e-6,
|
||||
"{lo:?} {hi:?}"
|
||||
);
|
||||
let bd = deduction(FRAC_PI_2, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (160.0 - 2.0 * bd, 120.0 - 2.0 * bd), 1e-6), "{:?}", flat_size(&mut s));
|
||||
let expect = tray_volume(100.0, 60.0, 30.0, t, r, gap);
|
||||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trapezoid_section_unfolds_through_72_and_108_degree_bends() {
|
||||
let (t, k, r) = (2.0, 0.5, 1.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
let bends = trapezoid_section(&mut s, t);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [0, 0, 150], "bends": bends}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 4 && rows.iter().all(|row| row.1 == "up" && (row.2 - 300.0).abs() < 1e-9), "{rows:?}");
|
||||
assert_eq!((rows.iter().filter(|row| row.0 == 72).count(), rows.iter().filter(|row| row.0 == 108).count()), (2, 2), "{rows:?}");
|
||||
let (a72, a108) = (72f64.to_radians(), 108f64.to_radians());
|
||||
let across = 200.0 + 2.0 * 40.0 + 2.0 * 15.0 - 2.0 * deduction(a72, r, t, k) - 2.0 * deduction(a108, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (across, 300.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||||
// The section keeps its outside faces: the returns' tops stay 40 sin 72° up and the length
|
||||
// stays; the widest point is on the round of the 108° bend (outer radius R + T), whose
|
||||
// centre lies (R + T) / sin 36° from the sharp corner along the corner's bisector.
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
let (c72, s72) = (a72.cos(), a72.sin());
|
||||
let rho = r + t;
|
||||
let bisector_x = (-c72 - 1.0) / (-c72 - 1.0).hypot(-s72);
|
||||
let wide = 2.0 * (100.0 + 40.0 * c72 + bisector_x * rho / 36f64.to_radians().sin() + rho);
|
||||
// (The box is measured on the tessellation: the round comes out a chord's sag inside.)
|
||||
assert!((hi[0] - lo[0] - wide).abs() < 2e-3 && (hi[1] - 40.0 * s72).abs() < 1e-6 && (hi[2] - lo[2] - 300.0).abs() < 1e-6, "{lo:?} {hi:?} {wide}");
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
let flat_box = [(hi[0] - lo[0], hi[1] - lo[1]), (hi[0] - lo[0], hi[2] - lo[2])];
|
||||
assert!(flat_box.iter().any(|b| same_size(*b, (across, 300.0), 1e-6)), "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_z_section_bends_up_at_one_edge_and_down_at_the_other() {
|
||||
let (t, k, r) = (2.0, 0.5, 1.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
// Flange 60, web 40, flange 30 on one skin: its first corner is the outside of its bend,
|
||||
// its second the inside.
|
||||
let bends = section(&mut s, &[(0.0, 0.0), (60.0, 0.0), (60.0, 40.0), (90.0, 40.0)], t, 100.0);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Section", "face": [30, 0, 50], "bends": bends}));
|
||||
let mut directions: Vec<String> = bend_rows(&mut s).into_iter().map(|row| row.1).collect();
|
||||
directions.sort();
|
||||
assert_eq!(directions, ["down", "up"]);
|
||||
// Outside setbacks R + T at the first bend, inside setbacks R at the second, and a 90°
|
||||
// allowance for each: 60 + 40 + 30 - 2 (R + T) - 2 R + π (R + K T) = 128.2832.
|
||||
let across = 130.0 - 2.0 * (r + t) - 2.0 * r + PI * (r + k * t);
|
||||
assert!((across - 128.283185).abs() < 1e-6);
|
||||
assert!(same_size(flat_size(&mut s), (across, 100.0), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(
|
||||
lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 90.0).abs() < 1e-6 && (hi[1] - 42.0).abs() < 1e-6 && (hi[2] - 100.0).abs() < 1e-6,
|
||||
"{lo:?} {hi:?}"
|
||||
);
|
||||
// Three flat strips and two quarter rounds on the neutral radius R + T/2.
|
||||
let expect = t * 100.0 * ((60.0 - (r + t)) + (40.0 - (r + t) - r) + (30.0 - r) + PI * (r + t / 2.0));
|
||||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tapered_tray_converts_with_72_degree_walls_and_slanted_corners() {
|
||||
let (t, k, r, gap) = (2.0, 0.5, 1.0, 0.5);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, gap);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Floor"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [200, 120]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Walls leaning out 18°: 72° bends from the floor.
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Floor", "distance": 40, "taper": "18 deg", "body_name": "Tray"}));
|
||||
let (lo, hi) = bbox(&mut s);
|
||||
assert!(hi[0] - lo[0] > 200.0 + 1.0, "the taper widens the top: {lo:?} {hi:?}");
|
||||
run(&mut s, "solid.shell", json!({"faces": [[100, 60, 40]], "thickness": t}));
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [100, 60, 0], "bends": [[100, 0, 0], [200, 60, 0], [100, 120, 0], [0, 60, 0]]}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 4 && rows.iter().all(|row| row.0 == 72 && row.1 == "up"), "{rows:?}");
|
||||
let slant = 40.0 / 72f64.to_radians().sin();
|
||||
let bd = deduction(72f64.to_radians(), r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (200.0 + 2.0 * slant - 2.0 * bd, 120.0 + 2.0 * slant - 2.0 * bd), 1e-6), "{:?}", flat_size(&mut s));
|
||||
// The outside stays; the rim is cut square to the sheet, as a blank is, so the inside edge
|
||||
// of a wall leaning out 18° stands T sin 18° above the solid's level rim.
|
||||
let (lo2, hi2) = bbox(&mut s);
|
||||
assert!((0..2).all(|i| (lo2[i] - lo[i]).abs() < 1e-6 && (hi2[i] - hi[i]).abs() < 1e-6) && lo2[2].abs() < 1e-6, "{lo2:?} {hi2:?}");
|
||||
assert!((hi2[2] - 40.0 - t * 18f64.to_radians().sin()).abs() < 1e-6, "{hi2:?}");
|
||||
let v = volume(&mut s);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sheet_follows_the_solid_through_parameter_changes_undo_and_reopening() {
|
||||
let (t, k, r) = (2.0, 0.44, 2.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
run(&mut s, "parameters.add", json!({"name": "L", "expression": "100 mm"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "T", "expression": "2 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": "L", "width": 60, "height": 30, "body_name": "Tray"}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": "T"}));
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
let bd = |t: f64| deduction(FRAC_PI_2, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (160.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6));
|
||||
// Longer: the right-hand bend edge moved from x = 100 to x = 140 and is found by its name.
|
||||
run(&mut s, "parameters.change", json!({"name": "L", "expression": "140 mm"}));
|
||||
assert!(s.model.results.iter().all(|x| x.error.is_none()), "{:?}", s.model.results.iter().map(|x| (&x.name, &x.error)).collect::<Vec<_>>());
|
||||
assert!(same_size(flat_size(&mut s), (200.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6), "{:?}", flat_size(&mut s));
|
||||
let (_, hi) = bbox(&mut s);
|
||||
assert!((hi[0] - 140.0).abs() < 1e-6, "{hi:?}");
|
||||
// Thicker walls: the sheet takes the shell's new thickness.
|
||||
run(&mut s, "parameters.change", json!({"name": "T", "expression": "3 mm"}));
|
||||
assert!(s.model.results.iter().all(|x| x.error.is_none()));
|
||||
assert!((flat(&mut s)["thickness"].as_f64().unwrap_or(0.0) - 3.0).abs() < 1e-9);
|
||||
let thick = (200.0 - 2.0 * bd(3.0), 120.0 - 2.0 * bd(3.0));
|
||||
assert!(same_size(flat_size(&mut s), thick, 1e-6), "{:?}", flat_size(&mut s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(same_size(flat_size(&mut s), (200.0 - 2.0 * bd(t), 120.0 - 2.0 * bd(t)), 1e-6));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(same_size(flat_size(&mut s), thick, 1e-6));
|
||||
// The design file keeps the bend edges, and reopens to the same sheet.
|
||||
let saved = s.doc.to_json();
|
||||
assert!(saved.contains("\"bends\""), "{saved}");
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
|
||||
assert!(same_size(flat_size(&mut reopened), thick, 1e-6));
|
||||
// Bend edges came with format 17: a build from before them asks for an update.
|
||||
let version = serde_json::from_str::<Value>(&saved).unwrap()["format"].as_str().and_then(solidcraft_doc::format::version_of);
|
||||
assert!(version >= Some(17), "{version:?}");
|
||||
}
|
||||
|
||||
/// Designs from before bend edges open unchanged: a plate conversion saves without `bends`, as
|
||||
/// it always did, and a format 16 file with one is the same document and the same sheet.
|
||||
#[test]
|
||||
fn designs_from_before_bend_edges_open_unchanged() {
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2, "body_name": "Plate"}));
|
||||
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [50, 30, 2]}));
|
||||
let mut older: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
assert!(!older.to_string().contains("\"bends\""), "{older}");
|
||||
older["format"] = json!("solidcraft/16");
|
||||
let doc = solidcraft_doc::Document::from_json(&older.to_string()).unwrap();
|
||||
assert_eq!(doc, *s.doc);
|
||||
let mut reopened = Session::new(doc);
|
||||
assert!(reopened.model.results.iter().all(|x| x.error.is_none()));
|
||||
assert!(same_size(flat_size(&mut reopened), (100.0, 60.0), 1e-9), "{:?}", flat_size(&mut reopened));
|
||||
assert!((volume(&mut reopened) - 100.0 * 60.0 * 2.0).abs() < 1e-6);
|
||||
assert_eq!(flat(&mut reopened), flat(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_edges_changed_in_an_edit_are_the_ones_bent() {
|
||||
let (t, k, r) = (2.0, 0.44, 2.0);
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
tray(&mut s, t);
|
||||
let made = run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
// Only the two long walls: a channel as long as the tray, 60 + 2 x 30 less two deductions
|
||||
// across. (By the names of the first two edges it would be the front and the right wall.)
|
||||
run(&mut s, "timeline.edit", json!({"feature": made["feature"], "set": {"bends": [BOTTOM_EDGES[0], BOTTOM_EDGES[2]]}}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 2 && rows.iter().all(|row| row.0 == 90 && row.1 == "up" && (row.2 - 100.0).abs() < 1e-6), "{rows:?}");
|
||||
let across = 120.0 - 2.0 * deduction(FRAC_PI_2, r, t, k);
|
||||
assert!(same_size(flat_size(&mut s), (100.0, across), 1e-6), "{:?} vs {across}", flat_size(&mut s));
|
||||
// Its end walls are no faces of the sheet any more: the channel is open at both ends.
|
||||
let expect = t * 100.0 * ((60.0 - 2.0 * (r + t)) + 2.0 * (30.0 - (r + t)) + PI * (r + t / 2.0));
|
||||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_flanges_and_cuts_work_on_a_converted_sheet() {
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||||
tray(&mut s, 2.0);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
let v0 = volume(&mut s);
|
||||
// A return flange on the front wall's top edge.
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 30]], "height": 10, "angle": 90}));
|
||||
let v1 = volume(&mut s);
|
||||
assert!(v1 > v0 + 500.0, "{v0} {v1}");
|
||||
assert_eq!(bend_rows(&mut s).len(), 5);
|
||||
// A hole cut in the flat stays in the base when the tray is folded again.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, 30], "radius": 5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "direction": "symmetric"}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
let hole = PI * 25.0 * 2.0;
|
||||
assert!((volume(&mut s) - (v1 - hole)).abs() < 2e-3 * v1, "{} vs {}", volume(&mut s), v1 - hole);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversions_that_cannot_work_say_why() {
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Wide", 2.0, 0.44, 40.0, 0.5);
|
||||
rule(&mut s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||||
tray(&mut s, 2.0);
|
||||
let err = |s: &mut Session, p: Value| s.execute("sheet.convert", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
// A bend that doesn't reach the fixed face through other bends.
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": [[0, 0, 15]]}));
|
||||
assert!(e.contains("does not connect"), "{e}");
|
||||
// No edge there, and no face there.
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": [[50, 30, 0]]}));
|
||||
assert!(e.contains("no edge"), "{e}");
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, -20], "bends": BOTTOM_EDGES}));
|
||||
assert!(e.contains("no face"), "{e}");
|
||||
// A loop of bends.
|
||||
let mut bends: Vec<[f64; 3]> = BOTTOM_EDGES.to_vec();
|
||||
bends.push([0.0, 0.0, 15.0]);
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": bends}));
|
||||
assert!(e.contains("loop"), "{e}");
|
||||
// Walls shorter than their bends' setbacks (a 40 mm bend radius on 30 mm walls).
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES, "rule": "Wide"}));
|
||||
assert!(e.contains("setback"), "{e}");
|
||||
// The sheet's values are the rule's; an empty list of bends is not a plate.
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES, "thickness": 3}));
|
||||
assert!(e.contains("the sheet metal rule sets it"), "{e}");
|
||||
let e = err(&mut s, json!({"body": "Tray", "face": [50, 30, 0], "bends": []}));
|
||||
assert!(e.contains("1…1000"), "{e}");
|
||||
// Curved faces.
|
||||
let mut c = Session::default();
|
||||
run(&mut c, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
|
||||
let e = err(&mut c, json!({"body": "Rod", "face": [0, 0, 30], "bends": [[10, 0, 30]]}));
|
||||
assert!(e.contains("curved"), "{e}");
|
||||
// Nothing changed.
|
||||
assert!(s.model.state().sheets.is_empty() && c.model.state().sheets.is_empty());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+250
-34
@@ -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)
|
||||
@@ -106,7 +106,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
||||
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
|
||||
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. Returns measured in unit (mm or deg)",
|
||||
),
|
||||
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
@@ -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")
|
||||
@@ -183,7 +183,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params("dimension: constraint id or parameter name; at: [x,y] text centre (sketch coordinates) | reset: true (default place)"),
|
||||
CommandSpec::new("sketch.options", "Sketch Palette Options", sketch_options).icon("sketch").params(
|
||||
"sketch?: id|name (default active); any of show_profile, show_points, show_dimensions, show_constraints, show_projected, grid, snap, slice, sketch_3d: bool. Returns them all",
|
||||
"sketch?: id|name (default active); any of show_profile, show_points, show_dimensions, show_constraints, show_projected, show_construction, grid, snap, slice, sketch_3d: bool. Returns them all",
|
||||
),
|
||||
CommandSpec::new("sketch.chain", "Chain", chain_cmd)
|
||||
.enabled(in_sketch)
|
||||
@@ -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
|
||||
@@ -401,6 +401,7 @@ pub(super) fn draw(
|
||||
let first = sk.points.len();
|
||||
let (curves, mut cons) = f(sk, doc)?;
|
||||
cons.extend(connect_points(sk, first, p)?);
|
||||
drop_implied_axes(sk, &mut cons);
|
||||
Ok((curves, cons))
|
||||
})
|
||||
}
|
||||
@@ -454,6 +455,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 +663,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 {
|
||||
@@ -803,6 +822,74 @@ fn rect_constraints(sk: &mut Sketch, l: &[usize]) -> Result<Vec<String>> {
|
||||
Ok(cons)
|
||||
}
|
||||
|
||||
/// Whether line `l` being horizontal (`horizontal`) or vertical already follows from another
|
||||
/// constraint than `own`: its ends coincide with the ends of a line constrained the same way (a
|
||||
/// side two rectangles share), or with two points constrained level (or plumb).
|
||||
fn axis_implied(sk: &Sketch, l: usize, horizontal: bool, own: &str) -> bool {
|
||||
let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|c| &c.kind) else { return false };
|
||||
let (a, b) = (*a, *b);
|
||||
// Coincident points as one (union-find over the coincident constraints).
|
||||
let mut parent: Vec<usize> = (0..sk.points.len()).collect();
|
||||
let root = |parent: &[usize], mut i: usize| {
|
||||
for _ in 0..parent.len() {
|
||||
match parent.get(i) {
|
||||
Some(&up) if up != i => i = up,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
i
|
||||
};
|
||||
for c in &sk.constraints {
|
||||
if let ConstraintKind::Coincident { p, q } = c.kind {
|
||||
let (rp, rq) = (root(&parent, p), root(&parent, q));
|
||||
if rp != rq
|
||||
&& let Some(slot) = parent.get_mut(rp)
|
||||
{
|
||||
*slot = rq;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (ra, rb) = (root(&parent, a), root(&parent, b));
|
||||
sk.constraints.iter().filter(|c| c.id != own).any(|c| {
|
||||
let ends = match (&c.kind, horizontal) {
|
||||
(ConstraintKind::Horizontal { l: m }, true) | (ConstraintKind::Vertical { l: m }, false) if *m != l => {
|
||||
match sk.curves.get(*m).map(|c| &c.kind) {
|
||||
Some(CurveKind::Line { a, b }) => Some((*a, *b)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
(ConstraintKind::HorizontalPoints { p, q }, true) | (ConstraintKind::VerticalPoints { p, q }, false) => Some((*p, *q)),
|
||||
_ => None,
|
||||
};
|
||||
ends.is_some_and(|(p, q)| {
|
||||
let (rp, rq) = (root(&parent, p), root(&parent, q));
|
||||
rp != rq && ((rp, rq) == (ra, rb) || (rp, rq) == (rb, ra))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Drawing adds horizontal/vertical only where it is not already implied: a rectangle drawn
|
||||
/// onto another's side shares that side's direction through the two coincident corners, so a
|
||||
/// second "vertical" there would only be redundant. Drops such constraints among `cons` (the
|
||||
/// drawing's own) from the sketch and the list.
|
||||
fn drop_implied_axes(sk: &mut Sketch, cons: &mut Vec<String>) {
|
||||
let mut dropped = Vec::new();
|
||||
for id in cons.iter() {
|
||||
let axis = sk.constraints.iter().find(|c| &c.id == id).and_then(|c| match c.kind {
|
||||
ConstraintKind::Horizontal { l } => Some((l, true)),
|
||||
ConstraintKind::Vertical { l } => Some((l, false)),
|
||||
_ => None,
|
||||
});
|
||||
if let Some((l, horizontal)) = axis
|
||||
&& axis_implied(sk, l, horizontal, id)
|
||||
{
|
||||
sk.constraints.retain(|c| &c.id != id);
|
||||
dropped.push(id.clone());
|
||||
}
|
||||
}
|
||||
cons.retain(|id| !dropped.contains(id));
|
||||
}
|
||||
|
||||
/// Which side and corner of an axis-aligned rectangle is which, from its lines and corner
|
||||
/// points as `polygon_lines` made them (counter-clockwise from the bottom-left corner), so a
|
||||
/// caller can dimension it without inspecting the sketch first.
|
||||
@@ -837,6 +924,7 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut cons = rect_constraints(sk, &l)?;
|
||||
mark_construction(sk, &l, p);
|
||||
cons.extend(connect_points(sk, first, p)?);
|
||||
drop_implied_axes(sk, &mut cons);
|
||||
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
|
||||
})?;
|
||||
Ok(rect_result(out, names, info))
|
||||
@@ -869,6 +957,7 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
mark_construction(sk, &l, p);
|
||||
cons.extend(connect_points(sk, first, p)?);
|
||||
drop_implied_axes(sk, &mut cons);
|
||||
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
|
||||
})?;
|
||||
Ok(rect_result(out, names, info))
|
||||
@@ -1154,21 +1243,26 @@ 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 mut residual = None;
|
||||
let (id, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let k = f(sk)?;
|
||||
let rep = solve(&mut sk.clone());
|
||||
before = Some(rep.dof);
|
||||
let order = entity_order(sk, p);
|
||||
let id = add_c(sk, k)?;
|
||||
if let Some((first, second)) = order {
|
||||
residual = residual_of(sk, &id);
|
||||
// A constraint that already holds needs nothing moved (even when both entities are
|
||||
// fully determined: it is then redundant, see `redundancy`).
|
||||
let holds = residual.is_some_and(|r| r <= solidcraft_sketch::CONSISTENT);
|
||||
if let Some((first, second)) = order.filter(|_| !holds) {
|
||||
apply_in_order(sk, &rep, &[first], second, cmd)?;
|
||||
}
|
||||
Ok(id)
|
||||
})?;
|
||||
reject_redundant(before, &info, cmd)?;
|
||||
Ok(json!({"constraint": id, "sketch": info}))
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
|
||||
constraint_result(p, cmd, id, info, by, Value::Null)
|
||||
}
|
||||
|
||||
/// A sketch entity a constraint is applied to.
|
||||
@@ -1179,7 +1273,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
|
||||
@@ -1312,18 +1406,79 @@ pub(super) fn dof_now(sk: &Sketch) -> usize {
|
||||
solve(&mut sk.clone()).dof
|
||||
}
|
||||
|
||||
/// A constraint that removes no degree of freedom over-constrains the sketch (Fusion refuses
|
||||
/// it); the caller's edit is rolled back by returning the error.
|
||||
pub(super) fn reject_redundant(before: Option<usize>, info: &Value, cmd: &str) -> Result<()> {
|
||||
/// A constraint just added that removes no degree of freedom (`before` → the edit's `info`) is
|
||||
/// redundant or a conflict. Redundant when it held where the sketch was before it was added
|
||||
/// (`residual`, see [`solidcraft_sketch::constraint_residual`]): what it asks for is already
|
||||
/// implied by other constraints, and the solver satisfies it with them. Returns the
|
||||
/// constraints that determine it (as "id (Name)"), or `None` when it removes a degree of
|
||||
/// freedom. A conflict is refused; the caller's edit is rolled back by returning the error.
|
||||
pub(super) fn redundancy(
|
||||
s: &Session,
|
||||
p: &Value,
|
||||
cmd: &str,
|
||||
before: Option<usize>,
|
||||
info: &Value,
|
||||
residual: Option<f64>,
|
||||
cid: &str,
|
||||
) -> Result<Option<Vec<String>>> {
|
||||
let after = info.get("dof").and_then(Value::as_u64).map(|d| d as usize);
|
||||
if let (Some(b), Some(a)) = (before, after)
|
||||
&& a >= b
|
||||
{
|
||||
return Err(bad(cmd, "that would over-constrain the sketch (it is already determined by other constraints)"));
|
||||
let (Some(b), Some(a)) = (before, after) else { return Ok(None) };
|
||||
if a < b {
|
||||
return Ok(None);
|
||||
}
|
||||
if !residual.is_some_and(|r| r <= solidcraft_sketch::CONSISTENT) {
|
||||
return Err(bad(cmd, "that would over-constrain the sketch (it conflicts with what the other constraints already determine)"));
|
||||
}
|
||||
Ok(Some(determined_by(s, p, cmd, cid)))
|
||||
}
|
||||
|
||||
/// The constraints that already determine constraint `cid` of the target sketch, as
|
||||
/// "id (Name)".
|
||||
fn determined_by(s: &Session, p: &Value, cmd: &str, cid: &str) -> Vec<String> {
|
||||
let Some(sk) = target_sketch(s, p, cmd).ok().and_then(|id| s.doc.sketch(id).ok()) else { return Vec::new() };
|
||||
let Some(ci) = sk.constraints.iter().position(|c| c.id == cid) else { return Vec::new() };
|
||||
solidcraft_sketch::determined_by(sk, ci)
|
||||
.into_iter()
|
||||
.filter_map(|k| sk.constraints.get(k))
|
||||
.map(|c| format!("{} ({})", c.id, c.kind.name()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// "k3 (Vertical), k9 (Vertical)", or "other constraints" when they are not known.
|
||||
pub(super) fn listed(by: &[String]) -> String {
|
||||
if by.is_empty() { "other constraints".to_string() } else { by.join(", ") }
|
||||
}
|
||||
|
||||
/// A redundant constraint is kept (and shown as redundant) unless the caller asked for
|
||||
/// `if_redundant: "refuse"` (drawing-time inferences, which only add what is not already there).
|
||||
pub(super) fn keep_redundant(p: &Value, cmd: &str, by: &[String]) -> Result<()> {
|
||||
if str_(p, "if_redundant").is_some_and(|m| m.eq_ignore_ascii_case("refuse")) {
|
||||
return Err(bad(cmd, format!("that would over-constrain the sketch (it is already determined by {})", listed(by))));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The result of a constraint command: the constraint, and whether it is redundant (and what
|
||||
/// determines it).
|
||||
pub(super) fn constraint_result(p: &Value, cmd: &str, id: String, info: Value, by: Option<Vec<String>>, extra: Value) -> Result<Value> {
|
||||
let mut out = json!({"constraint": id, "sketch": info});
|
||||
if let Some(by) = by {
|
||||
keep_redundant(p, cmd, &by)?;
|
||||
out["redundant"] = json!(true);
|
||||
out["determined_by"] = json!(by);
|
||||
}
|
||||
if let (Some(o), Value::Object(e)) = (out.as_object_mut(), extra) {
|
||||
o.extend(e);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The residual of the constraint `id` where the sketch is now (see
|
||||
/// [`solidcraft_sketch::constraint_residual`]).
|
||||
pub(super) fn residual_of(sk: &Sketch, id: &str) -> Option<f64> {
|
||||
sk.constraints.iter().position(|c| c.id == id).and_then(|ci| solidcraft_sketch::constraint_residual(sk, ci))
|
||||
}
|
||||
|
||||
fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.constraint.horizontal_vertical";
|
||||
let mode = str_(p, "mode").map(str::to_ascii_lowercase);
|
||||
@@ -1372,15 +1527,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 +1578,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 +1608,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 +1620,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());
|
||||
@@ -1582,6 +1747,8 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text_at = p.get("text_at").and_then(vec2);
|
||||
let driven = bool_(p, "driven").unwrap_or(false);
|
||||
let mut before = None;
|
||||
let (mut residual, mut new_id) = (None, String::new());
|
||||
let start = s.doc.clone();
|
||||
let ((param, kind_name, current), info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
before = Some(dof_now(sk));
|
||||
let ent = |r: &str| -> Result<(Option<usize>, Option<usize>)> {
|
||||
@@ -1738,8 +1905,13 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
(k, cur) = (ka, ca);
|
||||
}
|
||||
// How far the value is from what the geometry measures now: zero for a dimension that
|
||||
// only restates what the sketch already has.
|
||||
let want = vals.get(&pname).and_then(|v| v.to_kind(if is_angle { Kind::Angle } else { Kind::Length }).ok());
|
||||
residual = want.map(|w| (w - cur).abs()).filter(|r| r.is_finite());
|
||||
let name = k.name();
|
||||
let id = sk.add_constraint(k, Some(pname.clone()))?;
|
||||
new_id = id.clone();
|
||||
place_text(sk, &id, text_at);
|
||||
Ok((pname, name, measured(is_angle, cur)))
|
||||
})?;
|
||||
@@ -1748,8 +1920,28 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if driven {
|
||||
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
|
||||
}
|
||||
if reject_redundant(before, &info, cmd).is_err() {
|
||||
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
|
||||
// A dimension the sketch already determines cannot drive it: it is refused, or with
|
||||
// `if_redundant: "driven"` placed as a driven (reference) dimension instead.
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &new_id)
|
||||
.map_err(|_| bad(cmd, "that dimension conflicts with the sketch: other constraints already determine it with a different value"))?;
|
||||
if let Some(by) = by {
|
||||
if str_(p, "if_redundant").is_some_and(|m| m.eq_ignore_ascii_case("driven")) {
|
||||
s.doc = start;
|
||||
let mut q = p.clone();
|
||||
q["driven"] = json!(true);
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("if_redundant");
|
||||
o.remove("value");
|
||||
}
|
||||
let mut out = dimension(s, &q)?;
|
||||
out["redundant"] = json!(true);
|
||||
out["determined_by"] = json!(by);
|
||||
return Ok(out);
|
||||
}
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("that dimension is already determined by {}; add it as a driven (reference) dimension with driven: true", listed(&by)),
|
||||
));
|
||||
}
|
||||
let v = s.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default();
|
||||
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}))
|
||||
@@ -1999,6 +2191,11 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
|
||||
let (d, rep) = edit_solved(s, id, false, |sk, doc| {
|
||||
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
|
||||
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
|
||||
if let Some(link) = super::sketch_create::text_link(sk, r).map(str::to_owned) {
|
||||
let moving = sk.link_points(&link).into_iter().filter_map(|i| sk.point(i).map(|p| (i, p))).collect();
|
||||
super::sketch_create::move_text(sk, &link, to - from, 0.0, Vec2::ZERO, false)?;
|
||||
return Ok((drag::Dragged { reached: 1.0, moving }, Left::Unsolved));
|
||||
}
|
||||
// The held solves work on the dimensions' current values (a solved step applies them).
|
||||
doc.apply_dimension_values(&doc.param_values().0, sk)?;
|
||||
let (d, left) = drag_entity(sk, e, from, to, live);
|
||||
@@ -2055,6 +2252,7 @@ fn options_json(v: &solidcraft_sketch::SketchView) -> Value {
|
||||
"show_dimensions": !v.hide_dimensions,
|
||||
"show_constraints": !v.hide_constraints,
|
||||
"show_projected": !v.hide_projected,
|
||||
"show_construction": !v.hide_construction,
|
||||
"grid": !v.hide_grid,
|
||||
"snap": !v.no_snap,
|
||||
"slice": v.slice,
|
||||
@@ -2064,7 +2262,18 @@ fn options_json(v: &solidcraft_sketch::SketchView) -> Value {
|
||||
|
||||
fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.options";
|
||||
let keys = ["show_profile", "show_points", "show_dimensions", "show_constraints", "show_projected", "grid", "snap", "slice", "sketch_3d"];
|
||||
let keys = [
|
||||
"show_profile",
|
||||
"show_points",
|
||||
"show_dimensions",
|
||||
"show_constraints",
|
||||
"show_projected",
|
||||
"show_construction",
|
||||
"grid",
|
||||
"snap",
|
||||
"slice",
|
||||
"sketch_3d",
|
||||
];
|
||||
if let Some(o) = p.as_object()
|
||||
&& let Some(k) = o.keys().find(|k| *k != "sketch" && !keys.contains(&k.as_str()))
|
||||
{
|
||||
@@ -2087,6 +2296,7 @@ fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
put("show_dimensions", &mut v.hide_dimensions, true);
|
||||
put("show_constraints", &mut v.hide_constraints, true);
|
||||
put("show_projected", &mut v.hide_projected, true);
|
||||
put("show_construction", &mut v.hide_construction, true);
|
||||
put("grid", &mut v.hide_grid, true);
|
||||
put("snap", &mut v.no_snap, true);
|
||||
put("slice", &mut v.slice, false);
|
||||
@@ -2119,9 +2329,15 @@ 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;
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_ids_tests.rs"]
|
||||
mod ids_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_redundant_tests.rs"]
|
||||
mod redundant_tests;
|
||||
|
||||
@@ -150,7 +150,10 @@ fn wire_frame_include_other_component_refresh_break_lost_and_reopen() {
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
// Lost references retain the last coordinates and explicit marker, with no guessing.
|
||||
let sk = s.doc_mut().sketch_mut(sketch).unwrap();
|
||||
sk.links.iter_mut().find(|l| l.id == link).unwrap().source = solidcraft_sketch::LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO };
|
||||
let entry = sk.links.iter_mut().find(|l| l.id == link).unwrap();
|
||||
entry.source = solidcraft_sketch::LinkSource::Edge { body: "Missing".into(), at: Vec3::ZERO };
|
||||
// The link is the edge it names; without the name it is the edge nearest its point.
|
||||
entry.edge_name = None;
|
||||
run(&mut s, "sketch.line", json!({"points":[[2,0],[3,0]]}));
|
||||
let sk = s.doc.sketch(sketch).unwrap();
|
||||
assert!(sk.link(&link).unwrap().lost);
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
//! 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();
|
||||
// (A redundant one is kept unless refused: refused here.)
|
||||
assert!(s.execute("sketch.constraint.parallel", &json!({"a": "b", "b": "x", "if_redundant": "refuse"})).is_err(), "already horizontal");
|
||||
assert_eq!(s.doc.to_json(), before_redundant);
|
||||
assert!(axis_lines(&sketch(&s)).is_empty());
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
|
||||
|
||||
use super::sketch::{add_c, dof_now, edit, reject_redundant};
|
||||
use super::sketch::{add_c, constraint_result, dof_now, edit, listed, redundancy, residual_of};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, num, req_vec2, str_, string_list, vec2};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -56,15 +56,17 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
|
||||
fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.constraint.curvature";
|
||||
let mut before = None;
|
||||
let (mut before, mut residual) = (None, None);
|
||||
let (id, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let a = curve(sk, p.get("a"), cmd)?;
|
||||
let b = curve(sk, p.get("b"), cmd)?;
|
||||
before = Some(dof_now(sk));
|
||||
add_c(sk, ConstraintKind::Smooth { a, b })
|
||||
let id = add_c(sk, ConstraintKind::Smooth { a, b })?;
|
||||
residual = residual_of(sk, &id);
|
||||
Ok(id)
|
||||
})?;
|
||||
reject_redundant(before, &info, cmd)?;
|
||||
Ok(json!({"constraint": id, "sketch": info}))
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
|
||||
constraint_result(p, cmd, id, info, by, Value::Null)
|
||||
}
|
||||
|
||||
fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
|
||||
@@ -349,16 +351,18 @@ fn guess(sk: &Sketch, ents: &[String], cmd: &str) -> Result<ConstraintKind> {
|
||||
fn smart_constrain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.constrainer";
|
||||
let ents = string_list(p, "entities");
|
||||
let mut before = None;
|
||||
let (mut before, mut residual) = (None, None);
|
||||
let mut name = "";
|
||||
let (id, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let k = guess(sk, &ents, cmd)?;
|
||||
name = k.name();
|
||||
before = Some(dof_now(sk));
|
||||
add_c(sk, k)
|
||||
let id = add_c(sk, k)?;
|
||||
residual = residual_of(sk, &id);
|
||||
Ok(id)
|
||||
})?;
|
||||
reject_redundant(before, &info, cmd)?;
|
||||
Ok(json!({"constraint": id, "type": name, "sketch": info}))
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
|
||||
constraint_result(p, cmd, id, info, by, json!({"type": name}))
|
||||
}
|
||||
|
||||
fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -397,7 +401,10 @@ fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
c.driven = true;
|
||||
}
|
||||
before = Some(dof_now(&sk));
|
||||
reject_redundant(before, &info, cmd)?;
|
||||
// It holds (its parameter takes the value it measures), so it can only be redundant.
|
||||
if let Some(by) = redundancy(s, p, cmd, before, &info, Some(0.0), &cid)? {
|
||||
return Err(bad(cmd, format!("that dimension is already determined by {}; it stays driven", listed(&by))));
|
||||
}
|
||||
}
|
||||
s.doc_mut().prune_model_params();
|
||||
Ok(json!({"constraint": cid, "driven": driven, "sketch": info}))
|
||||
@@ -468,6 +475,8 @@ fn glyphs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"param": c.param,
|
||||
"at": anchor(&ss.sketch, &c.kind),
|
||||
"failing": failing.contains(&c.id),
|
||||
// Implied by the others (it removes no freedom) but holding: shown dimmed.
|
||||
"redundant": ss.report.redundant.contains(&c.id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -25,17 +25,17 @@ fn sketch(s: &Session) -> solidcraft_sketch::Sketch {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() {
|
||||
fn redundant_dimensions_are_refused_driven_ones_measure() {
|
||||
let mut s = new_sketch();
|
||||
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
|
||||
// Already horizontal (inferred).
|
||||
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": l})).unwrap_err().to_string();
|
||||
assert!(e.contains("over-constrain"), "{e}");
|
||||
// Already horizontal (inferred): kept as redundant, unless asked to refuse it.
|
||||
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": l, "if_redundant": "refuse"})).unwrap_err().to_string();
|
||||
assert!(e.contains("over-constrain") && e.contains("already determined by"), "{e}");
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{l}.start"), "b": "origin"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": 10}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(si["dof"], 0, "{si}");
|
||||
// A second length on it over-constrains: refused, unless driven.
|
||||
// A second length on it is already determined: refused, unless driven.
|
||||
assert!(s.execute("sketch.dimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
|
||||
assert_eq!(d["driven"], true);
|
||||
|
||||
@@ -10,6 +10,10 @@ use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, num, req_vec2, str_, string_list};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_text_move_tests.rs"]
|
||||
mod text_move_tests;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
|
||||
.at("SKETCH", "CREATE")
|
||||
@@ -75,19 +79,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 +110,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> {
|
||||
@@ -120,6 +137,39 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"link": l, "curves": curves}))
|
||||
}
|
||||
|
||||
/// The text source owning an outline or one of its linked points.
|
||||
pub(super) fn text_link<'a>(sk: &'a Sketch, entity: &str) -> Option<&'a str> {
|
||||
let link = sk
|
||||
.curve_index(entity)
|
||||
.and_then(|i| sk.curves.get(i))
|
||||
.and_then(|c| c.link.as_deref())
|
||||
.or_else(|| sk.resolve_point(entity).and_then(|i| sk.points.get(i)).and_then(|p| p.link.as_deref()))?;
|
||||
sk.link(link).filter(|source| source.kind == LinkKind::Text).map(|source| source.id.as_str())
|
||||
}
|
||||
|
||||
/// Transform a text source as a unit; generated outlines remain linked and editable.
|
||||
pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation: f64, center: Vec2, copy: bool) -> Result<Vec<usize>> {
|
||||
let Some(LinkSource::Text { text, at, height, angle, font }) = sk.link(link).map(|l| l.source.clone()) else {
|
||||
return Err(bad("sketch.move", "the selected source is not text"));
|
||||
};
|
||||
let q = at - center;
|
||||
let (sin, cos) = rotation.sin_cos();
|
||||
let at = center + Vec2::new(q.x * cos - q.y * sin, q.x * sin + q.y * cos) + translate;
|
||||
let angle = angle + rotation;
|
||||
let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), &text, at, height, angle)?;
|
||||
let source = LinkSource::Text { text, at, height, angle, font };
|
||||
let link = if copy {
|
||||
sk.add_link(LinkKind::Text, source, &geom)?
|
||||
} else {
|
||||
sk.set_link_source(link, source);
|
||||
if sk.update_link(link, &geom)? {
|
||||
return Err(bad("sketch.move", "moving text must preserve its outline topology; edit the text source first"));
|
||||
}
|
||||
link.to_owned()
|
||||
};
|
||||
Ok(sk.link_curves(&link))
|
||||
}
|
||||
|
||||
fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.edit_text";
|
||||
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
|
||||
@@ -131,12 +181,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));
|
||||
|
||||
@@ -95,7 +95,9 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad(cmd, "unsupported dimension role")),
|
||||
};
|
||||
let mut dimension = json!({"entities": entities, "type": ty, "value": value});
|
||||
// A typed size the sketch already has (a rectangle drawn onto another's side, the same
|
||||
// height) is placed as a driven (reference) dimension rather than refused.
|
||||
let mut dimension = json!({"entities": entities, "type": ty, "value": value, "if_redundant": "driven"});
|
||||
if let Some(sketch) = params.get("sketch") {
|
||||
dimension["sketch"] = sketch.clone();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch};
|
||||
use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch, conic_rho_from_point};
|
||||
|
||||
use super::{Ent, Left, counted_solve};
|
||||
|
||||
@@ -47,13 +47,18 @@ enum Path {
|
||||
Radius { c: usize, from: f64, to: f64 },
|
||||
/// An arc's ends move along their rays from the centre `o` to the cursor's radius.
|
||||
ArcRadius { o: Vec2, ends: Vec<(usize, Vec2)>, from: f64, to: f64 },
|
||||
/// A conic's `rho` follows the cursor, read off its position between the chord's midpoint
|
||||
/// and the apex (see [`solidcraft_sketch::conic_rho_from_point`]). `a`, `b` and `apex` stay
|
||||
/// put; only the curve's fullness changes, the way an ellipse's outline changes its minor
|
||||
/// radius rather than sliding the ellipse.
|
||||
ConicRho { c: usize, from: f64, to: f64 },
|
||||
}
|
||||
|
||||
impl Path {
|
||||
fn length(&self) -> f64 {
|
||||
match self {
|
||||
Path::Slide { d, .. } => d.len(),
|
||||
Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } => (to - from).abs(),
|
||||
Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } | Path::ConicRho { from, to, .. } => (to - from).abs(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +66,7 @@ impl Path {
|
||||
fn points(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
let pts = match self {
|
||||
Path::Slide { pts, .. } | Path::ArcRadius { ends: pts, .. } => pts.as_slice(),
|
||||
Path::Radius { .. } => &[],
|
||||
Path::Radius { .. } | Path::ConicRho { .. } => &[],
|
||||
};
|
||||
pts.iter().map(|(q, _)| *q)
|
||||
}
|
||||
@@ -90,13 +95,19 @@ impl Path {
|
||||
*r = from + (to - from) * t;
|
||||
}
|
||||
}
|
||||
Path::ConicRho { c, from, to } => {
|
||||
if let Some(CurveKind::Conic { rho, .. }) = sk.curves.get_mut(*c).map(|x| &mut x.kind) {
|
||||
*rho = from + (to - from) * t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line,
|
||||
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
|
||||
/// radius about its centre) and the rest of the sketch moves as little as it can, staying on the
|
||||
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line or spline
|
||||
/// slides by the cursor's move; a circle or arc grabbed on its edge changes radius about its
|
||||
/// centre; a conic grabbed on its curve reads its fullness, `rho`, off the cursor) and the rest
|
||||
/// of the sketch moves as little as it can, staying on the
|
||||
/// solution it started on. Fixed geometry stays. A step that cannot be solved with the entity
|
||||
/// held (the path crosses where the geometry cannot reach) is approached by halving the way to
|
||||
/// it, and the next steps are tried from the last solved state, the last of them being the whole
|
||||
@@ -430,8 +441,18 @@ fn plan(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold)> {
|
||||
hold.radii.push(c);
|
||||
Path::Radius { c, from: r, to: if minor > 1e-9 { minor } else { r } }
|
||||
}
|
||||
CurveKind::Conic { a, b, apex, rho } => {
|
||||
// Grabbed on the curve itself: its fullness (`rho`) follows the drag, the
|
||||
// way an ellipse's outline changes its minor radius rather than sliding the
|
||||
// ellipse. `a`, `b` and the apex (the tangent intersection, dragged
|
||||
// separately as an ordinary point) stay where they are.
|
||||
let (pa, px, pb) = (sk.point(a)?, sk.point(apex)?, sk.point(b)?);
|
||||
hold.points.extend([a, b, apex]);
|
||||
let to_rho = conic_rho_from_point(pa, px, pb, to);
|
||||
Path::ConicRho { c, from: rho, to: to_rho }
|
||||
}
|
||||
k => {
|
||||
// Lines, splines and conics slide with the cursor.
|
||||
// Lines and splines slide with the cursor.
|
||||
let mut ids = k.point_ids();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
|
||||
@@ -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 (or a panel's rim): 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
|
||||
&& 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);
|
||||
}
|
||||
}
|
||||
@@ -68,8 +68,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"),
|
||||
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("move")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool", created_doc!())),
|
||||
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())),
|
||||
CommandSpec::new("sketch.mirror", "Mirror", mirror)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("sketch_mirror")
|
||||
@@ -1458,19 +1460,35 @@ fn transform_in_place(sk: &mut Sketch, cs: &[usize], ps: &[usize], f: &dyn Fn(Ve
|
||||
|
||||
fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.move";
|
||||
let t = p.get("translate").and_then(vec2).unwrap_or(Vec2::ZERO);
|
||||
let ang = num(p, "angle").unwrap_or(0.0).to_radians();
|
||||
let t = if p.get("translate").is_some() { req_vec2(cmd, p, "translate")? } else { Vec2::ZERO };
|
||||
let ang = if p.get("angle").is_some() { num(p, "angle").ok_or_else(|| bad(cmd, "`angle` must be finite"))? } else { 0.0 }.to_radians();
|
||||
let copy = bool_(p, "copy").unwrap_or(false);
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (cs, ps) = entities(sk, p, cmd)?;
|
||||
let (mut cs, mut ps) = entities(sk, p, cmd)?;
|
||||
let c = if p.get("center").is_some() { point_or_ref(sk, p, "center", cmd)? } else { Vec2::ZERO };
|
||||
let rot = rotate(c, ang);
|
||||
let f = move |q: Vec2| rot(q) + t;
|
||||
let mut links = Vec::new();
|
||||
for entity in string_list(p, "entities") {
|
||||
if let Some(link) = super::sketch_create::text_link(sk, &entity)
|
||||
&& !links.iter().any(|old| old == link)
|
||||
{
|
||||
links.push(link.to_owned());
|
||||
}
|
||||
}
|
||||
cs.retain(|&i| !sk.curves.get(i).and_then(|c| c.link.as_ref()).is_some_and(|link| links.contains(link)));
|
||||
ps.retain(|&i| !sk.points.get(i).and_then(|p| p.link.as_ref()).is_some_and(|link| links.contains(link)));
|
||||
let mut text_curves = Vec::new();
|
||||
for link in links {
|
||||
text_curves.extend(super::sketch_create::move_text(sk, &link, t, ang, c, copy)?);
|
||||
}
|
||||
if copy {
|
||||
let (m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?;
|
||||
let (mut m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?;
|
||||
m.extend(text_curves);
|
||||
return Ok(ids_of(sk, &m));
|
||||
}
|
||||
transform_in_place(sk, &cs, &ps, &f, 1.0, cmd)?;
|
||||
cs.extend(text_curves);
|
||||
Ok(ids_of(sk, &cs))
|
||||
})?;
|
||||
Ok(json!({"curves": out, "sketch": info}))
|
||||
|
||||
@@ -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| {
|
||||
@@ -202,9 +226,12 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The link is the edge that was picked, not whichever is nearest the picked point later.
|
||||
let edge_name = solidcraft_doc::project::resolved_edge_name(&st, &r);
|
||||
let link = sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?;
|
||||
if let Some(l) = sk.links.iter_mut().find(|l| l.id == link) {
|
||||
l.face_name = name;
|
||||
l.edge_name = edge_name;
|
||||
}
|
||||
Ok(link)
|
||||
}
|
||||
@@ -454,7 +481,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 +518,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 +566,15 @@ 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_project_edge_tests.rs"]
|
||||
mod edge_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_model_plane_tests.rs"]
|
||||
mod model_plane_tests;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! A projected edge stays the edge that was picked: its link keeps the edge's persistent name,
|
||||
//! so the edge is found by that name wherever a change of the body takes it, and no other edge
|
||||
//! is taken for it when it is split or gone.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::{Vec2, Vec3};
|
||||
use solidcraft_sketch::{Link, LinkSource, Shape};
|
||||
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A plate `width` (100) x 50 x 20 with a corner at the origin: body `Body1`.
|
||||
fn plate(s: &mut Session) {
|
||||
run(s, "parameters.add", json!({"name": "width", "expression": "100 mm"}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 50]}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "width"}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": "Base", "distance": "20 mm", "name": "Plate"}));
|
||||
}
|
||||
|
||||
/// Sketch `Top` on the plate's top (z = 20) with the top edge of the plate's right-hand side,
|
||||
/// from (100, 0, 20) to (100, 50, 20), projected into it: picked at `y` along it.
|
||||
fn project_right_edge(s: &mut Session, y: f64) {
|
||||
run(s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "Top"}));
|
||||
run(s, "sketch.project", json!({"refs": [{"edge": [100, y, 20], "body": "Body1"}]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
fn set_width(s: &mut Session, width: f64) {
|
||||
run(s, "parameters.change", json!({"name": "width", "expression": format!("{width} mm")}));
|
||||
}
|
||||
|
||||
/// What sketch `Top` has to report.
|
||||
fn trouble(s: &Session) -> String {
|
||||
let r = s.model.result(s.doc.find_feature("Top").unwrap().id).unwrap();
|
||||
format!("{}{}", r.error.clone().unwrap_or_default(), r.warning.clone().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The link of sketch `Top` as the model has it.
|
||||
fn link(s: &Session) -> Link {
|
||||
s.model.state().sketch(s.doc.find_feature("Top").unwrap().id).unwrap().sketch.links[0].clone()
|
||||
}
|
||||
|
||||
/// The link of sketch `Top` as the design stores it.
|
||||
fn stored(s: &Session) -> Link {
|
||||
s.doc.sketch(s.doc.find_feature("Top").unwrap().id).unwrap().links[0].clone()
|
||||
}
|
||||
|
||||
/// The ends of the line the link of sketch `Top` gives.
|
||||
fn projected(s: &Session) -> (Vec2, Vec2) {
|
||||
let st = s.model.state();
|
||||
let sk = &st.sketch(s.doc.find_feature("Top").unwrap().id).unwrap().sketch;
|
||||
match sk.shape(sk.link_curves(&sk.links[0].id)[0]) {
|
||||
Some(Shape::Line { a, b }) => (a, b),
|
||||
other => panic!("not a line: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The projected line runs along y at `x`, between `y0` and `y1`.
|
||||
#[track_caller]
|
||||
fn assert_line(s: &Session, x: f64, (y0, y1): (f64, f64), when: &str) {
|
||||
let (a, b) = projected(s);
|
||||
let ends = (a.y.min(b.y), a.y.max(b.y));
|
||||
assert!(
|
||||
(a.x - x).abs() < 1e-6 && (b.x - x).abs() < 1e-6 && (ends.0 - y0).abs() < 1e-6 && (ends.1 - y1).abs() < 1e-6,
|
||||
"{when}: expected the line x = {x}, y = {y0}..{y1}: {a:?} {b:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The name of the edge of `Body1` whose middle is `mid`.
|
||||
fn edge_named_at(s: &mut Session, mid: [f64; 3]) -> String {
|
||||
let edges = run(s, "model.edges", json!({"body": "Body1"}));
|
||||
let at = |e: &Value| Vec3::new(e["mid"][0].as_f64().unwrap(), e["mid"][1].as_f64().unwrap(), e["mid"][2].as_f64().unwrap());
|
||||
let edge = edges["edges"].as_array().unwrap().iter().find(|e| at(e).dist(Vec3::new(mid[0], mid[1], mid[2])) < 1e-6).unwrap();
|
||||
edge["name"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// The picked point (100, 25, 20) is 25 from the front and back edges of the top: wider than
|
||||
/// 125 they are nearer to it than the picked edge is. At 60 the picked edge is 40 from it,
|
||||
/// further than a reference found by its point is looked for (a quarter of the body's diagonal,
|
||||
/// 20.2). The link follows the edge by its name through all of them.
|
||||
#[test]
|
||||
fn a_projected_edge_stays_that_edge_when_the_body_changes_size() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
project_right_edge(&mut s, 25.0);
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
|
||||
let name = edge_named_at(&mut s, [100.0, 25.0, 20.0]);
|
||||
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
|
||||
for width in [150.0, 60.0, 400.0] {
|
||||
set_width(&mut s, width);
|
||||
assert_eq!(trouble(&s), "", "width {width}");
|
||||
assert_line(&s, width, (0.0, 50.0), &format!("width {width}"));
|
||||
assert!(!link(&s).lost, "width {width}");
|
||||
}
|
||||
// Saved and opened again.
|
||||
let opened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(stored(&opened).edge_name.as_deref(), Some(name.as_str()));
|
||||
assert_line(&opened, 400.0, (0.0, 50.0), "opened");
|
||||
// Undo and redo of the change.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_line(&s, 60.0, (0.0, 50.0), "undone");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_line(&s, 400.0, (0.0, 50.0), "redone");
|
||||
// Editing the sketch keeps the edge, and stores the link's point where the edge is now.
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
|
||||
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert_line(&s, 400.0, (0.0, 50.0), "edited");
|
||||
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
|
||||
assert!(matches!(&stored(&s).source, LinkSource::Edge { body, at } if body == "Body1" && at.dist(Vec3::new(400.0, 25.0, 20.0)) < 1e-6));
|
||||
}
|
||||
|
||||
/// A slot cut through the plate from x = 80 to 120, y = 20 to 30 leaves the picked edge in two
|
||||
/// pieces, y = 0..20 and y = 30..50. The link, picked at y = 10, gives the piece it was picked
|
||||
/// on and says the edge was split; without the slot it is the whole edge again.
|
||||
#[test]
|
||||
fn a_projected_edge_that_was_split_gives_its_nearest_piece_and_says_so() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [80, 20], "p1": [120, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "SlotSketch", "distance": "20 mm", "operation": "cut", "name": "Slot"}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
|
||||
project_right_edge(&mut s, 10.0);
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": false}));
|
||||
assert_line(&s, 100.0, (0.0, 20.0), "split");
|
||||
assert!(trouble(&s).contains("was split"), "{}", trouble(&s));
|
||||
assert!(!link(&s).lost);
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "whole again");
|
||||
assert_eq!(trouble(&s), "");
|
||||
}
|
||||
|
||||
/// A fillet of radius 4 takes the picked edge away. Its two tangent edges are 4 from the picked
|
||||
/// point, the nearest edges there are now: the link is lost and keeps its line, it takes
|
||||
/// neither of them. Without the fillet the link has its edge again.
|
||||
#[test]
|
||||
fn a_projected_edge_that_is_gone_is_lost_not_replaced() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[100, 25, 20]], "radius": "4 mm", "name": "Round"}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
|
||||
project_right_edge(&mut s, 25.0);
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "drawn");
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": false}));
|
||||
let said = trouble(&s);
|
||||
assert!(said.contains("projected reference lost: edge") && said.contains("no longer exists"), "{said}");
|
||||
assert!(link(&s).lost);
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "the last geometry is kept");
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert!(!link(&s).lost);
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "found again");
|
||||
}
|
||||
|
||||
/// A boss 20 x 20 x 10 made as its own body on the plate and joined into it afterwards: no body
|
||||
/// has the link's body name any more. The join left the boss's top edge from (30, 10, 30) to
|
||||
/// (30, 30, 30) as it was, and the link finds it by its name on the body that has it now.
|
||||
#[test]
|
||||
fn a_projected_edge_whose_body_was_joined_into_another_is_found_there() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "BossSketch"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "BossSketch", "distance": "10 mm", "operation": "new", "name": "Boss", "body_name": "Boss"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Body1", "tools": ["Boss"], "operation": "join"}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Combine1", "suppressed": true}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "30 mm", "name": "Top"}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [30, 20, 30], "body": "Boss"}]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_line(&s, 30.0, (10.0, 30.0), "drawn");
|
||||
assert!(stored(&s).edge_name.is_some());
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Combine1", "suppressed": false}));
|
||||
assert!(s.model.state().body("Boss").is_none(), "the boss is part of the plate now");
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert!(!link(&s).lost);
|
||||
assert_line(&s, 30.0, (10.0, 30.0), "joined");
|
||||
assert!(matches!(&link(&s).source, LinkSource::Edge { body, .. } if body == "Body1"));
|
||||
}
|
||||
|
||||
/// A design saved before links kept the edge's name (format 16) has a link with a point only.
|
||||
/// It opens and is found by that point, as it was, and takes the edge's name when its sketch is
|
||||
/// next edited: undoing the edit takes the name away again, redoing it brings it back.
|
||||
#[test]
|
||||
fn an_edge_link_saved_without_a_name_is_found_as_before_and_named_by_the_next_edit() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
project_right_edge(&mut s, 25.0);
|
||||
let name = edge_named_at(&mut s, [100.0, 25.0, 20.0]);
|
||||
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
let links = saved["features"].as_array_mut().unwrap().iter_mut().filter_map(|f| f.pointer_mut("/sketch/links")?.as_array_mut());
|
||||
let removed = links.flatten().filter_map(|l| l.as_object_mut()?.remove("edge_name")).count();
|
||||
assert_eq!(removed, 1, "the one projected edge");
|
||||
saved["format"] = json!("solidcraft/16");
|
||||
let mut s = Session::new(solidcraft_doc::Document::from_json(&saved.to_string()).unwrap());
|
||||
assert_eq!(s.doc.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT), "upgraded on opening");
|
||||
assert_eq!(stored(&s).edge_name, None);
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "opened");
|
||||
// Found by its point: 0.2 further out the picked edge is still the nearest one.
|
||||
set_width(&mut s, 100.2);
|
||||
assert_eq!(stored(&s).edge_name, None);
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert_line(&s, 100.2, (0.0, 50.0), "width 100.2");
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
|
||||
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
|
||||
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(stored(&s).edge_name, None);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(stored(&s).edge_name.as_deref(), Some(name.as_str()));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
set_width(&mut s, 150.0);
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert_line(&s, 150.0, (0.0, 50.0), "width 150");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -526,3 +526,62 @@ fn faces_split_by_hidden_seams_project_whole() {
|
||||
assert_eq!(radii.len(), 4, "{round}");
|
||||
assert!(radii[..2].iter().all(|r| (r - 17.).abs() < 1e-6) && radii[2..].iter().all(|r| (r - 20.).abs() < 1e-6), "{round}");
|
||||
}
|
||||
|
||||
/// #635: a rectangle whose corner is made coincident with a projected edge of the solid under
|
||||
/// the sketch, so that its bottom side lies on that edge, is still one closed profile (the
|
||||
/// projected line stays, and names the shared edge as it was there first), and it extrudes to
|
||||
/// its own volume, also when the design is opened again.
|
||||
#[test]
|
||||
fn a_rectangle_put_onto_a_projected_edge_stays_a_closed_profile() {
|
||||
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": 20}));
|
||||
let block = s.model.state().bodies[0].name.clone();
|
||||
let id = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
||||
// The solid's bottom edge along X, from (0, 0) to (40, 0).
|
||||
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 0, 0]}]}));
|
||||
let projected = r["links"][0]["curves"][0].as_str().unwrap().to_string();
|
||||
// A 20 x 15 rectangle clear of it is a closed profile of its own.
|
||||
let sides = ids(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 10], "p1": [25, 25]}))["curves"]);
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [sides[0]], "value": 20}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [sides[1]], "value": 15}));
|
||||
let mut own = sides.clone();
|
||||
own.sort();
|
||||
let profile_curves = |si: &Value| -> Vec<Vec<String>> {
|
||||
si["profiles"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut c = ids(&p["curves"]);
|
||||
c.sort();
|
||||
c
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(profile_curves(&inspect(&mut s)), vec![own.clone()]);
|
||||
// Its bottom-left corner onto the projected line: the bottom side lies on it.
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", sides[0]), "b": projected}));
|
||||
let si = inspect(&mut s);
|
||||
assert_eq!(si["status"], "solved", "{si}");
|
||||
let curve = |id: &str| si["curves"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone();
|
||||
let bottom = curve(&sides[0]);
|
||||
for end in ["start_at", "end_at"] {
|
||||
assert!(bottom[end][1].as_f64().unwrap().abs() < 1e-9, "{bottom}");
|
||||
}
|
||||
assert!(curve(&projected).get("link").is_some(), "the projected line stays");
|
||||
let mut on_line = vec![projected.clone(), sides[1].clone(), sides[2].clone(), sides[3].clone()];
|
||||
on_line.sort();
|
||||
assert_eq!(profile_curves(&si), vec![on_line], "{si}");
|
||||
assert!((si["profiles"][0]["area_mm2"].as_f64().unwrap() - 300.0).abs() < 1e-6, "{si}");
|
||||
// Picked by a point inside, it extrudes to a 20 x 15 x 10 block of its own.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": id, "profiles": [{"point": [15, 7.5]}], "distance": 10, "operation": "new", "body_name": "Pad"}));
|
||||
let volume = |s: &Session, name: &str| s.model.state().body(name).unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
assert!((volume(&s, "Pad") - 3000.0).abs() < 1e-6, "{}", volume(&s, "Pad"));
|
||||
assert!((volume(&s, &block) - 24000.0).abs() < 1e-6, "{}", volume(&s, &block));
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((volume(&reopened, "Pad") - 3000.0).abs() < 1e-6, "{}", volume(&reopened, "Pad"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Redundant but consistent constraints (#687): rectangles of one size drawn around a shared
|
||||
//! corner (a "clover"), or onto each other's sides, imply some of their constraints twice. Those
|
||||
//! are kept (and reported as redundant) or not added at all; only constraints that conflict
|
||||
//! with the sketch are refused.
|
||||
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_sketch() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn sketch(s: &Session) -> solidcraft_sketch::Sketch {
|
||||
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone()
|
||||
}
|
||||
|
||||
fn report(s: &Session) -> solidcraft_sketch::SolveReport {
|
||||
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().report.clone()
|
||||
}
|
||||
|
||||
fn glyphs(s: &mut Session) -> Value {
|
||||
run(s, "sketch.glyphs", json!({}))
|
||||
}
|
||||
|
||||
/// The four quadrants around `c`, each `w` square: bottom-left, bottom-right, top-right,
|
||||
/// top-left (each next one shares a side with the one before; the last shares two).
|
||||
fn quadrants(c: [f64; 2], w: f64) -> [([f64; 2], [f64; 2]); 4] {
|
||||
let [x, y] = c;
|
||||
[([x - w, y - w], [x, y]), ([x, y - w], [x + w, y]), ([x, y], [x + w, y + w]), ([x - w, y], [x, y + w])]
|
||||
}
|
||||
|
||||
fn horizontal_vertical(sk: &solidcraft_sketch::Sketch) -> usize {
|
||||
sk.constraints
|
||||
.iter()
|
||||
.filter(|c| matches!(c.kind, solidcraft_sketch::ConstraintKind::Horizontal { .. } | solidcraft_sketch::ConstraintKind::Vertical { .. }))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The owner's steps: rectangles of one size (typed 10 × 10) drawn snapped onto a shared
|
||||
/// corner, 2, 3 and 4 of them. Every one is accepted; a size the sketch already has becomes a
|
||||
/// driven (reference) dimension, and the sketch ends fully constrained.
|
||||
#[test]
|
||||
fn a_clover_of_same_size_rectangles_drawn_by_snapping_is_accepted() {
|
||||
for n in 2..=4 {
|
||||
let mut s = new_sketch();
|
||||
for (i, (a, b)) in quadrants([0.0, 0.0], 10.0).iter().take(n).enumerate() {
|
||||
let r = s.execute(
|
||||
"sketch.draw.dimensioned",
|
||||
&json!({"command": "sketch.rectangle.two_point", "params": {"p0": a, "p1": b}, "dimensions": {"width": 10, "height": 10}}),
|
||||
);
|
||||
let r = r.unwrap_or_else(|e| panic!("rectangle {i} of {n}: {e}"));
|
||||
assert_eq!(r["sketch"]["solved"], true, "{r}");
|
||||
}
|
||||
let g = glyphs(&mut s);
|
||||
assert_eq!(g["fully_constrained"], true, "{n} rectangles: {g}");
|
||||
assert_eq!(g["conflicts"], json!([]), "{g}");
|
||||
let sk = sketch(&s);
|
||||
assert_eq!(sk.curves.len(), 4 * n);
|
||||
// Shared sides carry one horizontal/vertical, not two: 4 for the first rectangle, 3 for
|
||||
// each with one shared side, 2 for the last of four (two shared sides).
|
||||
let expected = [0, 4, 7, 10, 12][n];
|
||||
assert_eq!(horizontal_vertical(&sk), expected, "{n} rectangles");
|
||||
// Each later rectangle's size along a shared side is a reference dimension.
|
||||
let driven = sk.constraints.iter().filter(|c| c.driven).count();
|
||||
assert_eq!(driven, [0, 0, 1, 2, 4][n], "{n} rectangles");
|
||||
for (a, b) in quadrants([0.0, 0.0], 10.0).iter().take(n) {
|
||||
for corner in [Vec2::new(a[0], a[1]), Vec2::new(b[0], b[1])] {
|
||||
assert!(sk.points.iter().any(|p| p.pos.dist(corner) < 1e-9), "{n} rectangles: corner {corner:?} moved");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Undimensioned, the same clover is drawn without a single redundant constraint: shared
|
||||
/// sides are not constrained twice, and the freedom left is the two column widths and the two
|
||||
/// row heights.
|
||||
#[test]
|
||||
fn rectangles_sharing_sides_do_not_constrain_a_side_twice() {
|
||||
let mut s = new_sketch();
|
||||
for (a, b) in quadrants([50.0, 50.0], 10.0) {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": a, "p1": b}));
|
||||
}
|
||||
let rep = report(&s);
|
||||
assert!(rep.ok(), "{rep:?}");
|
||||
assert_eq!(rep.redundant, Vec::<String>::new());
|
||||
// Free: the shared corner (2), two column widths, two row heights.
|
||||
assert_eq!(rep.dof, 6);
|
||||
assert_eq!(horizontal_vertical(&sketch(&s)), 12);
|
||||
}
|
||||
|
||||
/// Two rectangles on one side: the second only adds its width.
|
||||
#[test]
|
||||
fn shared_edge_rectangles() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [25, 10]}));
|
||||
// Three of its sides' directions and its two corners on the first one.
|
||||
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(5), "{r}");
|
||||
let rep = report(&s);
|
||||
assert_eq!(rep.dof, 3, "the first one's width and height, the second one's width");
|
||||
assert!(rep.redundant.is_empty());
|
||||
// Sized and placed, all of it is fixed.
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 10}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l5"], "value": 15}));
|
||||
assert_eq!(glyphs(&mut s)["fully_constrained"], true);
|
||||
}
|
||||
|
||||
/// Two separately drawn 10 × 10 squares snapped corner to corner: the second snap is implied by
|
||||
/// the first and the equal sizes. It is kept, reported as redundant along with what implies
|
||||
/// it, and the degrees of freedom and the fully constrained state are unchanged.
|
||||
#[test]
|
||||
fn snapping_a_second_corner_of_same_size_rectangles_is_redundant_not_refused() {
|
||||
let mut s = new_sketch();
|
||||
for (a, b) in [([0.0, 0.0], [10.0, 10.0]), ([20.0, 0.0], [30.0, 10.0])] {
|
||||
run(
|
||||
&mut s,
|
||||
"sketch.draw.dimensioned",
|
||||
json!({"command": "sketch.rectangle.two_point", "params": {"p0": a, "p1": b}, "dimensions": {"width": 10, "height": 10}}),
|
||||
);
|
||||
}
|
||||
// The second square's bottom-left corner onto the first one's bottom-right.
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "l5.start", "b": "l1.end"}));
|
||||
let dof = report(&s).dof;
|
||||
assert_eq!(dof, 0, "the first square is on the origin, the second one placed by its corner");
|
||||
// Its top-left corner is already on the first one's top-right.
|
||||
let r = run(&mut s, "sketch.constraint.coincident", json!({"a": "l7.end", "b": "l2.end"}));
|
||||
assert_eq!(r["redundant"], true, "{r}");
|
||||
assert!(!r["determined_by"].as_array().is_none_or(Vec::is_empty), "{r}");
|
||||
let rep = report(&s);
|
||||
assert_eq!((rep.ok(), rep.dof), (true, 0));
|
||||
assert_eq!(rep.redundant, vec![r["constraint"].as_str().unwrap().to_string()]);
|
||||
let g = glyphs(&mut s);
|
||||
assert_eq!(g["fully_constrained"], true);
|
||||
let flagged: Vec<&Value> = g["glyphs"].as_array().unwrap().iter().filter(|x| x["redundant"] == true).collect();
|
||||
assert_eq!(flagged.len(), 1, "{g}");
|
||||
// Drawing-time inferences ask for redundant ones to be left out.
|
||||
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "l8", "if_redundant": "refuse"})).unwrap_err().to_string();
|
||||
assert!(e.contains("already determined by"), "{e}");
|
||||
}
|
||||
|
||||
/// A size the sketch already has, dimensioned again: refused as already determined (naming
|
||||
/// what determines it), or placed as a driven dimension; an equal constraint the sketch already
|
||||
/// implies is kept as redundant.
|
||||
#[test]
|
||||
fn redundant_equal_dimensions() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [20, 10]}));
|
||||
let height = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
|
||||
let k = sketch(&s).constraints.iter().find(|c| c.param.as_deref() == height["param"].as_str()).map(|c| c.id.clone()).unwrap();
|
||||
// The second one's right side is as tall as the shared side.
|
||||
let e = s.execute("sketch.dimension", &json!({"entities": ["l6"], "value": 10})).unwrap_err().to_string();
|
||||
assert!(e.contains("already determined by") && e.contains(&k), "{e}");
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l6"], "value": 10, "if_redundant": "driven"}));
|
||||
assert_eq!((d["driven"].clone(), d["redundant"].clone()), (json!(true), json!(true)), "{d}");
|
||||
// Equal widths, twice: the second is implied by the first.
|
||||
run(&mut s, "sketch.constraint.equal", json!({"a": "l1", "b": "l5"}));
|
||||
let before = report(&s).dof;
|
||||
let r = run(&mut s, "sketch.constraint.equal", json!({"a": "l3", "b": "l7"}));
|
||||
assert_eq!(r["redundant"], true, "{r}");
|
||||
assert_eq!(report(&s).dof, before);
|
||||
// Its driving value changes still solve with the redundant constraints present.
|
||||
let pname = height["param"].as_str().unwrap().to_string();
|
||||
run(&mut s, "parameters.change", json!({"name": pname, "expression": "14 mm"}));
|
||||
let sk = sketch(&s);
|
||||
assert!(report(&s).ok());
|
||||
let len = |id: &str| {
|
||||
let c = sk.curve_index(id).unwrap();
|
||||
let solidcraft_sketch::CurveKind::Line { a, b } = sk.curves[c].kind else { panic!() };
|
||||
sk.points[a].pos.dist(sk.points[b].pos)
|
||||
};
|
||||
assert!((len("l6") - 14.0).abs() < 1e-7 && (len("l4") - 14.0).abs() < 1e-7);
|
||||
}
|
||||
|
||||
/// A constraint that does not hold where the others put the geometry still conflicts.
|
||||
#[test]
|
||||
fn a_conflicting_constraint_is_still_refused() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [20, 10]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
|
||||
let before = sketch(&s).constraints.len();
|
||||
// The second one's right side cannot be 12 while the shared side is 10.
|
||||
let e = s.execute("sketch.dimension", &json!({"entities": ["l6"], "value": 12})).unwrap_err().to_string();
|
||||
assert!(e.contains("over-constrain") || e.contains("conflicts"), "{e}");
|
||||
let e = s.execute("sketch.dimension", &json!({"entities": ["l6"], "value": 12, "if_redundant": "driven"})).unwrap_err().to_string();
|
||||
assert!(e.contains("over-constrain") || e.contains("conflicts"), "{e}");
|
||||
// A vertical side cannot be horizontal.
|
||||
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "l2", "mode": "horizontal"})).unwrap_err().to_string();
|
||||
assert!(e.contains("over-constrain"), "{e}");
|
||||
assert_eq!(sketch(&s).constraints.len(), before);
|
||||
assert!(report(&s).redundant.is_empty());
|
||||
}
|
||||
|
||||
/// Dragging the shared corner of a clover moves all four rectangles with it, redundant
|
||||
/// constraints and all.
|
||||
#[test]
|
||||
fn dragging_the_shared_corner_moves_every_rectangle() {
|
||||
let mut s = new_sketch();
|
||||
for (a, b) in quadrants([50.0, 50.0], 10.0) {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": a, "p1": b}));
|
||||
}
|
||||
// A redundant equal and a redundant vertical, so the drag has some to carry.
|
||||
run(&mut s, "sketch.constraint.equal", json!({"a": "l1", "b": "l13"}));
|
||||
let r = run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l4"}));
|
||||
assert_eq!(r["redundant"], true, "{r}");
|
||||
assert!(!report(&s).redundant.is_empty());
|
||||
let centre = Vec2::new(50.0, 50.0);
|
||||
let id = sketch(&s).points.iter().find(|p| p.pos.dist(centre) < 1e-9).map(|p| p.id.clone()).unwrap();
|
||||
run(&mut s, "sketch.drag", json!({"entity": id, "from": [50, 50], "to": [56, 47]}));
|
||||
let sk = sketch(&s);
|
||||
let rep = report(&s);
|
||||
assert!(rep.ok(), "{rep:?}");
|
||||
let to = Vec2::new(56.0, 47.0);
|
||||
// Each rectangle still has a corner on the moved one and is still a rectangle.
|
||||
let near: usize = sk.points.iter().filter(|p| p.pos.dist(to) < 1e-6).count();
|
||||
assert!(near >= 4, "{near} corners at the dragged spot");
|
||||
for l in &sk.curves {
|
||||
let solidcraft_sketch::CurveKind::Line { a, b } = l.kind else { continue };
|
||||
let d = sk.points[b].pos - sk.points[a].pos;
|
||||
assert!(d.x.abs() < 1e-7 || d.y.abs() < 1e-7, "{} is no longer axis-aligned: {d:?}", l.id);
|
||||
}
|
||||
assert!(sk.points.iter().all(|p| p.pos.dist(centre) > 1e-6), "nothing stayed behind at the old corner");
|
||||
}
|
||||
|
||||
/// A horizontal on a line that already is (it was drawn so, inferred) is redundant, not an
|
||||
/// error; the degrees of freedom do not change, and the sketch still turns fully constrained.
|
||||
#[test]
|
||||
fn an_explicit_redundant_constraint_keeps_the_dof_and_full_constraint() {
|
||||
let mut s = new_sketch();
|
||||
let l = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"][0].as_str().unwrap().to_string();
|
||||
let r = run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": l}));
|
||||
assert_eq!(r["redundant"], true, "{r}");
|
||||
// Its start is on the origin (snapped): only its length is free.
|
||||
assert_eq!(report(&s).dof, 1);
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": 10}));
|
||||
let g = glyphs(&mut s);
|
||||
assert_eq!((g["dof"].clone(), g["fully_constrained"].clone()), (json!(0), json!(true)), "{g}");
|
||||
assert!(report(&s).point_determined.iter().all(|d| *d));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use super::*;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
|
||||
s.execute(cmd, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
fn setup() -> (Session, String, String) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
let result = run(&mut s, "sketch.text", json!({"text":"II","at":[3,4],"height":10,"angle":15}));
|
||||
(s, result["link"].as_str().unwrap().into(), result["curves"][0].as_str().unwrap().into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_move_updates_the_source_once_preserves_ids_and_roundtrips() {
|
||||
let (mut s, link, curve) = setup();
|
||||
let before = s.doc.to_json();
|
||||
let ids: Vec<_> = s.doc.sketch(1).unwrap().curves.iter().map(|c| c.id.clone()).collect();
|
||||
let undo = s.undo.len();
|
||||
run(&mut s, "sketch.move", json!({"entities":[curve,ids[1]],"translate":[10,-2],"center":[3,4],"angle":30}));
|
||||
let sk = s.doc.sketch(1).unwrap();
|
||||
assert_eq!(sk.curves.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), ids);
|
||||
let LinkSource::Text { at, angle, text, height, font } = &sk.link(&link).unwrap().source else { panic!() };
|
||||
assert!(at.dist(Vec2::new(13.0, 2.0)) < 1e-9);
|
||||
assert!((*angle - 45_f64.to_radians()).abs() < 1e-12);
|
||||
assert_eq!((text.as_str(), *height, font), ("II", 10.0, &None));
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let moved = s.doc.to_json();
|
||||
let reopened = solidcraft_doc::Document::from_json(&moved).unwrap();
|
||||
assert_eq!(reopened.to_json(), moved);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), moved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_copy_is_a_new_editable_source_and_original_is_unchanged() {
|
||||
let (mut s, link, curve) = setup();
|
||||
let original = s.doc.sketch(1).unwrap().link(&link).unwrap().source.clone();
|
||||
let result = run(&mut s, "sketch.move", json!({"entities":[curve],"translate":[20,0],"copy":true}));
|
||||
let copied_curve = result["curves"][0].as_str().unwrap();
|
||||
let new_link = text_link(s.doc.sketch(1).unwrap(), copied_curve).unwrap().to_owned();
|
||||
assert_ne!(new_link, link);
|
||||
run(&mut s, "sketch.edit_text", json!({"link":new_link,"text":"O"}));
|
||||
assert_eq!(s.doc.sketch(1).unwrap().link(&link).unwrap().source, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_drag_moves_all_letters_and_rebuilds_downstream_on_release() {
|
||||
let (mut s, link, curve) = setup();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":3}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch":1}));
|
||||
let sk = s.doc.sketch(1).unwrap();
|
||||
let before = sk.link_points(&link).into_iter().map(|i| (i, sk.point(i).unwrap())).collect::<Vec<_>>();
|
||||
for frame in 0..3 {
|
||||
let result = run(&mut s, "sketch.drag", json!({"entity":curve,"from":[frame,0],"to":[frame+1,1],"live":true}));
|
||||
assert_eq!(result["reached"], 1.0);
|
||||
assert!(s.model.is_stale());
|
||||
}
|
||||
run(&mut s, "sketch.drag_end", json!({}));
|
||||
assert!(!s.model.is_stale());
|
||||
assert!(s.model.result(2).is_some_and(|r| r.error.is_none()));
|
||||
let sk = s.doc.sketch(1).unwrap();
|
||||
for (i, old) in before {
|
||||
assert!(sk.point(i).unwrap().dist(old + Vec2::new(3.0, 3.0)) < 1e-8);
|
||||
}
|
||||
assert!(matches!(sk.link(&link).unwrap().source, LinkSource::Text { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_move_in_a_rotated_component_preserves_font_and_local_coordinates() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Label"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":component["occurrence"],"translate":[30,20,10],"axis":[0,0,1],"angle":90}));
|
||||
let created = run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
let sid = created["sketch"].as_u64().unwrap();
|
||||
let result = run(&mut s, "sketch.text", json!({"text":"I","at":[0,0],"height":10,"font":solidcraft_sketch::DEFAULT_FONT_FAMILY}));
|
||||
let link = result["link"].as_str().unwrap();
|
||||
let before = s.world_state().sketch(sid).unwrap().plane;
|
||||
run(&mut s, "sketch.move", json!({"entities":[result["curves"][0]],"translate":[5,2]}));
|
||||
let sk = s.doc.sketch(sid).unwrap();
|
||||
let LinkSource::Text { at, font, .. } = &sk.link(link).unwrap().source else { panic!() };
|
||||
assert!(at.dist(Vec2::new(5.0, 2.0)) < 1e-9);
|
||||
assert_eq!(font.as_deref(), Some(solidcraft_sketch::DEFAULT_FONT_FAMILY));
|
||||
let state = s.world_state();
|
||||
assert!(state.sketch(sid).unwrap().plane.to_world(*at).dist(before.to_world(Vec2::new(5.0, 2.0))) < 1e-9);
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.doc.sketch(sid).unwrap().link(link).unwrap().source, sk.link(link).unwrap().source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_move_rejects_malformed_values_without_changing_history() {
|
||||
let (mut s, _, curve) = setup();
|
||||
let before = s.doc.to_json();
|
||||
let undo = s.undo.len();
|
||||
for params in [
|
||||
json!({"entities":[curve],"translate":[1]}),
|
||||
json!({"entities":[curve],"angle":"invalid"}),
|
||||
json!({"entity":curve,"from":[0,0],"to":["invalid",2]}),
|
||||
] {
|
||||
let cmd = if params.get("entity").is_some() { "sketch.drag" } else { "sketch.move" };
|
||||
assert!(s.execute(cmd, ¶ms).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
@@ -482,6 +482,63 @@ fn palette_options_are_kept_with_each_sketch() {
|
||||
assert!(!sketch(&s).view.hide_points);
|
||||
}
|
||||
|
||||
/// Construction Geometries is a display option of one sketch, saved with it, and it changes
|
||||
/// nothing about the geometry: construction curves were already outside every profile, and
|
||||
/// they still are while hidden. Centerlines are a line type of their own and keep showing. A
|
||||
/// design written before the option existed carries no key for it and loads with construction
|
||||
/// geometry shown.
|
||||
#[test]
|
||||
fn hiding_construction_geometry_is_per_sketch_saved_and_leaves_profiles_and_centerlines_alone() {
|
||||
use crate::view::sketch_curve_visible;
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 45], [40, 45]], "connect": false, "infer": false}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 55], [40, 55]], "connect": false, "infer": false}));
|
||||
run(&mut s, "sketch.construction", json!({"curves": ["l5"], "value": true}));
|
||||
run(&mut s, "sketch.centerline", json!({"curves": ["l6"], "value": true}));
|
||||
let (rect, construction, centerline) = (0, 4, 5);
|
||||
let profiles = run(&mut s, "sketch.inspect", json!({}))["profiles"].clone();
|
||||
assert_eq!(profiles.as_array().map(Vec::len), Some(1), "the rectangle is one profile: {profiles}");
|
||||
|
||||
assert_eq!(run(&mut s, "sketch.options", json!({}))["options"]["show_construction"], true);
|
||||
let o = run(&mut s, "sketch.options", json!({"show_construction": false}));
|
||||
assert_eq!(o["options"]["show_construction"], false);
|
||||
let sk = sketch(&s);
|
||||
assert!(sk.view.hide_construction);
|
||||
assert!(!sketch_curve_visible(&sk, construction), "construction geometry is hidden");
|
||||
assert!(sketch_curve_visible(&sk, centerline), "a centerline is its own line type");
|
||||
assert!(sketch_curve_visible(&sk, rect), "ordinary geometry stays");
|
||||
// Hiding is display only: the curves, their line types and the profiles are untouched.
|
||||
let after = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(after["profiles"], profiles);
|
||||
assert_eq!(after["curves"][construction]["construction"], true);
|
||||
assert!(sketch(&s).curves[centerline].centerline, "the centerline keeps its line type");
|
||||
// Undo puts the option back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!sketch(&s).view.hide_construction);
|
||||
run(&mut s, "sketch.options", json!({"show_construction": false}));
|
||||
|
||||
// Another sketch shows construction geometry; the first keeps the option after finishing.
|
||||
let first = s.active_sketch.expect("sketch");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
|
||||
assert_eq!(run(&mut s, "sketch.options", json!({}))["options"]["show_construction"], true);
|
||||
assert_eq!(run(&mut s, "sketch.options", json!({"sketch": first}))["options"]["show_construction"], false);
|
||||
|
||||
// Saved with the design.
|
||||
let text = serde_json::to_string(s.doc.sketch(first).expect("first")).expect("json");
|
||||
assert!(text.contains("hide_construction"), "{text}");
|
||||
let back: Sketch = serde_json::from_str(&text).expect("parse");
|
||||
assert!(back.view.hide_construction);
|
||||
|
||||
// An older design file has no `hide_construction` key at all: `SketchView`'s serde default
|
||||
// fills it in, and construction geometry shows, the way it did before the option existed.
|
||||
let older = r#"{"points":[{"id":"origin","pos":[0.0,0.0],"fixed":true}],"curves":[],"constraints":[],
|
||||
"view":{"hide_projected":true,"slice":true}}"#;
|
||||
let old: Sketch = serde_json::from_str(older).expect("an older sketch still loads");
|
||||
assert!(old.view.hide_projected && old.view.slice && !old.view.hide_construction);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Dragging
|
||||
|
||||
@@ -534,6 +591,32 @@ fn dragging_circles_and_arcs() {
|
||||
assert!(pt(&s, "a1.start").dist(v(70.0, 0.0)) < 1e-9, "{:?}", pt(&s, "a1.start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_conic_reads_rho_off_the_cursor_and_its_apex_slides_like_any_point() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.conic", json!({"start": [0, 0], "end": [40, 0], "apex": [20, 20]}));
|
||||
let rho = |s: &Session| match sketch(s).curves[0].kind {
|
||||
solidcraft_sketch::CurveKind::Conic { rho, .. } => rho,
|
||||
ref k => panic!("not a conic: {k:?}"),
|
||||
};
|
||||
assert!((rho(&s) - 0.5).abs() < 1e-9, "default rho: {}", rho(&s));
|
||||
|
||||
// Grabbed on the curve itself (its midpoint, at the default rho of 0.5): `rho` follows the
|
||||
// cursor, read off where it falls between the chord's midpoint (20, 0) and the apex (20,
|
||||
// 20). The three defining points stay exactly where they were.
|
||||
drag(&mut s, "k1", [20.0, 10.0], [20.0, 14.0]);
|
||||
assert!((rho(&s) - 0.7).abs() < 1e-6, "rho after drag: {}", rho(&s));
|
||||
assert!(pt(&s, "k1.start").dist(v(0.0, 0.0)) < 1e-9);
|
||||
assert!(pt(&s, "k1.end").dist(v(40.0, 0.0)) < 1e-9);
|
||||
assert!(pt(&s, "k1.apex").dist(v(20.0, 20.0)) < 1e-9);
|
||||
|
||||
// Grabbed on the apex itself: it slides like any other point (not a `rho` edit), and `rho`
|
||||
// is unaffected.
|
||||
drag(&mut s, "k1.apex", [20.0, 20.0], [10.0, 30.0]);
|
||||
assert!(pt(&s, "k1.apex").dist(v(10.0, 30.0)) < 1e-9);
|
||||
assert!((rho(&s) - 0.7).abs() < 1e-6, "rho after apex drag: {}", rho(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_geometry_resists_drags() {
|
||||
let mut s = new_sketch();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user