Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0872eb959a | ||
|
|
3e13eb6392 | ||
|
|
6f115ad740 | ||
|
|
b3df2c382b | ||
|
|
d824d36821 | ||
|
|
849426e866 | ||
|
|
0ee5e97db5 | ||
|
|
81d9b7e04a | ||
|
|
6ef0b22118 | ||
|
|
873a65fd4d | ||
|
|
1473893b08 | ||
|
|
96356dc544 | ||
|
|
50c5c13f72 | ||
|
|
7195e323bf | ||
|
|
fd19efc6fc | ||
|
|
9eddb1cbd3 | ||
|
|
47517578a8 | ||
|
|
d6f167a88a | ||
|
|
af2f09fac2 | ||
|
|
7391e8ed75 | ||
|
|
1ec33fcdc7 | ||
|
|
faeeff1c64 | ||
|
|
ee9b4b9fb1 | ||
|
|
8535d229ff | ||
|
|
5c2fa3e9ca | ||
|
|
464a6dfad4 | ||
|
|
a026a69182 | ||
|
|
406293fd53 | ||
|
|
bd8db8ea1c | ||
|
|
b4edf05373 | ||
|
|
41f0c3d0c6 | ||
|
|
784648af2a | ||
|
|
38bd16713f | ||
|
|
b1695873f5 | ||
|
|
c008addae2 | ||
|
|
9dc12b4d0f | ||
|
|
2a4907884b | ||
|
|
ed22d663e4 | ||
|
|
d73cd8a177 | ||
|
|
c159a3e277 | ||
|
|
16c49c3e86 | ||
|
|
734f69ad72 | ||
|
|
2e7f41710b | ||
|
|
5c6f70cc00 | ||
|
|
2cab09f65b | ||
|
|
861ab027b6 | ||
|
|
9935133b56 | ||
|
|
a261aaa841 | ||
|
|
e4ef6cfe29 | ||
|
|
1cc65cbc91 | ||
|
|
099d7e298a | ||
|
|
a629236650 | ||
|
|
a4a8b48567 | ||
|
|
78079587ad | ||
|
|
01b1b70586 | ||
|
|
5744b1f327 | ||
|
|
1a67390a53 | ||
|
|
1337404c06 | ||
|
|
8f081679bc | ||
|
|
beb3ea01e7 | ||
|
|
c715986a2a | ||
|
|
d20b0fab65 | ||
|
|
0ddd660089 | ||
|
|
b6e87f0c44 | ||
|
|
731629a422 | ||
|
|
f6a47a49d7 | ||
|
|
289b521a65 | ||
|
|
771a11feab | ||
|
|
95ea05e7c8 | ||
|
|
241dac1542 |
No files matched your search
@@ -205,16 +205,17 @@ jobs:
|
||||
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)
|
||||
# the whole matrix runs nightly (or by hand) in shards of about equal cost, cut by
|
||||
# tests/action_matrix/costs.json. Keep SHARDS in step with the matrix below.
|
||||
name: Action matrix (${{ matrix.shard }}/16)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 55
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
part: [1, 2, 3, 4]
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
@@ -223,12 +224,48 @@ jobs:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- name: Run a quarter of the matrix
|
||||
- name: Run one shard of the matrix
|
||||
# A step limit below the job's, so the outcomes so far are still uploaded.
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
PART: ${{ matrix.part }}
|
||||
SHARDS: 16
|
||||
SHARD: ${{ matrix.shard }}
|
||||
OUT: ${{ github.workspace }}/action-matrix
|
||||
run: |
|
||||
mkdir -p "$OUT"
|
||||
export ACTION_MATRIX_SHARD="$SHARD/$SHARDS" ACTION_MATRIX_RESULTS="$OUT/results-$SHARD.json" \
|
||||
ACTION_MATRIX_TIMINGS="$OUT/timings-$SHARD.jsonl" ACTION_MATRIX_WORKDIR="$OUT/raw-$SHARD"
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_shard)"
|
||||
- name: Upload the outcomes and timings
|
||||
# `cargo xtask action-matrix --from DIR --bless` (with every shard's files in DIR)
|
||||
# records them in the baseline and costs.json.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: action-matrix-${{ matrix.shard }}
|
||||
path: action-matrix
|
||||
retention-days: 14
|
||||
|
||||
action-matrix-determinism:
|
||||
# A sample of the matrix run twice on one runner: every action must come out the same.
|
||||
name: Action matrix determinism
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
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 sample twice
|
||||
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)"
|
||||
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_determinism)"
|
||||
|
||||
doc-tests:
|
||||
name: Doc tests
|
||||
@@ -306,11 +343,38 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-fxc
|
||||
- run: cargo test -p solidcraft-gpu --profile ci --locked
|
||||
# One test at a time: WARP's shader JIT crashes now and then (a null read in
|
||||
# d3d10warp.dll's JIT optimizer, about 1 run in 60) when several devices compile compute
|
||||
# pipelines at once, which the parallel tests do. Minidumps showed it in WARP itself, not
|
||||
# in SolidCraft or FXC; serial runs never hit it.
|
||||
- run: cargo test -p solidcraft-gpu --profile ci --locked -- --test-threads=1
|
||||
|
||||
# The Windows app starts D3D12 first and only the backend it uses (a Vulkan driver crashed the
|
||||
# app at startup on Intel). Start the real app on the runner (its D3D12 adapter is WARP) with
|
||||
# several saved settings and safe mode, and check each draws a frame and quits cleanly.
|
||||
windows-startup:
|
||||
name: Windows app startup
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-startup
|
||||
- name: Backend order and probing on Windows
|
||||
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests
|
||||
- name: Build the app
|
||||
run: cargo build -p solidcraft --profile ci --locked
|
||||
- name: Start the app under different settings
|
||||
shell: pwsh
|
||||
run: tools/windows-startup-smoke.ps1 -Exe target/ci/solidcraft.exe
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc]
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -325,6 +389,7 @@ jobs:
|
||||
DOCS: ${{ needs.doc-tests.result }}
|
||||
ORACLE: ${{ needs.oracle.result }}
|
||||
WINDOWS_FXC: ${{ needs.windows-fxc.result }}
|
||||
WINDOWS_STARTUP: ${{ needs.windows-startup.result }}
|
||||
run: |
|
||||
test "$CHANGES" = success
|
||||
test "$CHECKS" = success
|
||||
@@ -335,6 +400,7 @@ jobs:
|
||||
test "$DOCS" = success
|
||||
test "$ORACLE" = success
|
||||
test "$WINDOWS_FXC" = success
|
||||
test "$WINDOWS_STARTUP" = success
|
||||
fi
|
||||
|
||||
wasm:
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Publishing a GitHub release (not a prerelease) opens a pull request at microsoft/winget-pkgs
|
||||
# that adds the new version of bherbruck.SolidCraft, so `winget install bherbruck.SolidCraft`
|
||||
# and `winget upgrade` get it. Komac (https://github.com/russellbanks/Komac) downloads the three
|
||||
# versioned MSIs, reads their product codes and copies the rest from the previous manifest.
|
||||
# Skipped until the owner adds the WINGET_TOKEN secret, and while the package isn't in
|
||||
# winget-pkgs yet; see docs/releasing.md.
|
||||
name: WinGet
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Published release tag to submit (e.g. v0.11.1)
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: winget
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
PACKAGE_ID: bherbruck.SolidCraft
|
||||
KOMAC_VERSION: 2.16.0
|
||||
KOMAC_SHA256: 7d2707fa6210f2789a3702de49fbd150b736dbf426ee0b9bc8e098736f9fd82d
|
||||
|
||||
jobs:
|
||||
submit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: check
|
||||
name: Check for the token and the package
|
||||
env:
|
||||
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
run: |
|
||||
if [ -z "$WINGET_TOKEN" ]; then
|
||||
echo "No WINGET_TOKEN secret: nothing to submit."
|
||||
exit 0
|
||||
fi
|
||||
case "$TAG" in
|
||||
v[0-9]*.[0-9]*.[0-9]*-*|*nightly*) echo "$TAG is a prerelease: not submitted."; exit 0 ;;
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "::error::Unexpected tag '$TAG'"; exit 1 ;;
|
||||
esac
|
||||
if ! gh api "repos/microsoft/winget-pkgs/contents/manifests/b/bherbruck/SolidCraft" --silent; then
|
||||
echo "$PACKAGE_ID isn't in winget-pkgs yet: nothing to update."
|
||||
exit 0
|
||||
fi
|
||||
echo "version=${TAG#v}" >>"$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
- name: Install Komac
|
||||
if: steps.check.outputs.version != ''
|
||||
run: |
|
||||
archive="komac-${KOMAC_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl -fsSLo "$RUNNER_TEMP/$archive" \
|
||||
"https://github.com/russellbanks/Komac/releases/download/v${KOMAC_VERSION}/$archive"
|
||||
echo "$KOMAC_SHA256 $RUNNER_TEMP/$archive" | sha256sum -c -
|
||||
tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" komac
|
||||
- name: Open the winget-pkgs pull request
|
||||
if: steps.check.outputs.version != ''
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
VERSION: ${{ steps.check.outputs.version }}
|
||||
TAG: ${{ steps.check.outputs.tag }}
|
||||
run: |
|
||||
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/$TAG"
|
||||
"$RUNNER_TEMP/komac" update "$PACKAGE_ID" --version "$VERSION" \
|
||||
--urls "$base/solidcraft-$VERSION-windows-x64.msi" \
|
||||
"$base/solidcraft-$VERSION-windows-x86.msi" \
|
||||
"$base/solidcraft-$VERSION-windows-arm64.msi" \
|
||||
--release-notes-url "https://github.com/${GITHUB_REPOSITORY}/releases/tag/$TAG" \
|
||||
--submit
|
||||
@@ -27,9 +27,11 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
|
||||
## Never crash
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception is two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (that crate denies it instead): macOS hands Finder files
|
||||
only to an Objective-C method.
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate
|
||||
(which denies it instead), have no safe API: two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
|
||||
method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
|
||||
returns an error ("not supported yet: …"); it never panics.
|
||||
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
|
||||
|
||||
+6
-1
@@ -14,6 +14,9 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 |
|
||||
| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/pbr_gpu.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
|
||||
@@ -89,9 +92,11 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
@@ -146,7 +151,7 @@ in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
|
||||
|---|---|---|---|
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, 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/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; corner choice at vertices several intersection curves end on (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
## Vendored renderer compatibility fix
|
||||
|
||||
@@ -27,9 +27,11 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
|
||||
## Never crash
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception is two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (that crate denies it instead): macOS hands Finder files
|
||||
only to an Objective-C method.
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate
|
||||
(which denies it instead), have no safe API: two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
|
||||
method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
|
||||
returns an error ("not supported yet: …"); it never panics.
|
||||
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
|
||||
|
||||
+3
-2
@@ -107,8 +107,9 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
`cargo xtask assets` enforces this.
|
||||
4. **Never crash.** People trust a CAD app with their work.
|
||||
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
|
||||
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events),
|
||||
and the compiler denies it.
|
||||
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events)
|
||||
and `apps/solidcraft/src/native_crash.rs` (Windows native crash capture), and the compiler
|
||||
denies it.
|
||||
- An unsupported case returns an error ("not supported yet: …"); it never panics.
|
||||
- Every call into truck goes through `solidcraft_kernel::guard`.
|
||||
- Every crash fix comes with a regression test.
|
||||
|
||||
Generated
+13
@@ -3466,6 +3466,7 @@ dependencies = [
|
||||
"solidcraft-engine",
|
||||
"solidcraft-ui-egui",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
]
|
||||
|
||||
@@ -3478,6 +3479,15 @@ dependencies = [
|
||||
"solidcraft-mcp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.11.0"
|
||||
@@ -3486,8 +3496,10 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solidcraft-derive",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"solidcraft-render",
|
||||
"solidcraft-sketch",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
@@ -3577,6 +3589,7 @@ name = "solidcraft-render"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-geom",
|
||||
|
||||
@@ -24,10 +24,15 @@ solidcraft-io = { path = "crates/io" }
|
||||
solidcraft-engine = { path = "crates/engine" }
|
||||
solidcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
solidcraft-mcp = { path = "crates/mcp" }
|
||||
solidcraft-derive = { path = "crates/derive" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
# derive macros (crates/derive); already in the tree through serde_derive
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
sha2 = "0.10"
|
||||
@@ -53,6 +58,7 @@ ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
||||
|
||||
# images
|
||||
image = { version = "0.25", default-features = false, features = ["png", "hdr"] }
|
||||
png = "0.18.1"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
|
||||
@@ -21,6 +21,8 @@ model through the built-in MCP server.
|
||||
- **Windows:** download and run the [installer][windows-msi].
|
||||
Also for [ARM64][windows-arm64-msi] and [32-bit][windows-x86-msi] PCs, or as a
|
||||
[portable zip][windows-portable] that runs without installing.
|
||||
With [WinGet](https://learn.microsoft.com/windows/package-manager/winget/):
|
||||
`winget install bherbruck.SolidCraft`.
|
||||
- **macOS:** open the [disk image][macos-dmg] and drag SolidCraft into Applications
|
||||
(Apple silicon and Intel).
|
||||
- **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it.
|
||||
@@ -128,12 +130,16 @@ rebuild with no errors, replayed here at 2× speed.
|
||||
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
|
||||
assemblies with joints as URDF for robot simulators (docs/urdf.md);
|
||||
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
|
||||
need B-rep bodies). Convert Mesh reconstructs supported closed constant-section parts
|
||||
need B-rep bodies). The `mesh.convert` command reconstructs supported closed constant-section parts
|
||||
as parametric solids with planar and circular cylindrical faces;
|
||||
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
|
||||
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
|
||||
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
|
||||
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
|
||||
- **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the
|
||||
integrated product viewport and CPU preview PNGs. Existing simple colors remain supported.
|
||||
Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented.
|
||||
See [opaque surfaces](docs/render-opaque-pbr.md).
|
||||
- **Display meshes** from our own tessellation planner: each face becomes a plan of (u, v)
|
||||
points and triangles that shares its edge nodes with its neighbours, so the mesh is watertight.
|
||||
Facets follow the chord tolerance, the facet angle and a longest side, with cells at most 2:1
|
||||
@@ -161,6 +167,7 @@ cargo run --release -p solidcraft -- --sample # desktop app with a samp
|
||||
cargo run --release -p solidcraft-cli -- commands # the command registry
|
||||
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
|
||||
cargo run --release -p solidcraft-cli -- render examples/render-studio.json --out product.png
|
||||
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
|
||||
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
|
||||
|
||||
+59
-4
@@ -59,6 +59,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| 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 |
|
||||
|
||||
M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option +
|
||||
pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets
|
||||
retain their saved two-finger scroll preference; camera motion is available through
|
||||
`view.navigate` and modifier-aware `ui.move` automation.
|
||||
|
||||
## Physical material data
|
||||
|
||||
- Physical material card validation (#923/#922) provides typed SI mechanical and
|
||||
@@ -89,9 +94,41 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Render foundation
|
||||
|
||||
- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated
|
||||
product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass
|
||||
stay unchanged. HDRI, transmission and textures remain separate gates.
|
||||
See [the consumer and limitations](docs/render-opaque-pbr.md).
|
||||
|
||||
- Combined integration is tracked in #842, with independent scalar libraries in
|
||||
#863 and richer PBR/material-card transport in #1086. See [phase boundaries and validation gates](docs/render-integration.md).
|
||||
Render/library consumers use the merged asynchronous picker (#1127). Material-card
|
||||
UI (#1094), texture mapping and HDRI lighting remain explicit follow-ups.
|
||||
|
||||
- R4a adds bounded CPU product-image jobs, cancellation and atomic PNG export through
|
||||
engine commands, the Render panel, CLI and persistent MCP. Immutable captures and
|
||||
results stay outside persistence/undo; native workers and cooperative browser tasks
|
||||
share the same work protocol. See [jobs, limits and export](docs/render-jobs.md).
|
||||
|
||||
- R3 adds an overlay-free product scene and the integrated Render workspace, with
|
||||
runtime GPU host capabilities, per-document drafts and named camera/background
|
||||
controls. Design keeps its camera and drawing policy. Raster transparency remains
|
||||
approximate; PBR and environments are later slices. See
|
||||
[workspace and scene contracts](docs/render-workspace.md).
|
||||
|
||||
- R2 adds bounded named render setups in document format 21 and the read/save
|
||||
`render.settings.get/set` commands. Proven render-only changes preserve geometry
|
||||
and prepared world data through undo/redo and checkpoint restore. This slice does
|
||||
not add a Render workspace, PBR, environments or image jobs; see
|
||||
[settings and refresh policy](docs/render-settings.md).
|
||||
- Bounded PNG/JPEG texture admission (#900) validates in-memory sources and returns
|
||||
completed RGBA8 pixels with explicit color/data roles. It is an input utility only:
|
||||
ICC/16-bit normalization, local wood mapping, resource persistence and the renderer
|
||||
consumer remain separate work. See [limits and licensed-file probes](docs/render-texture-input.md).
|
||||
|
||||
- The initial opaque surface numerical contract for #1004 provides bounded linear
|
||||
color/metallic/roughness/IOR admission and an original tested direct-light BRDF.
|
||||
It does not enable PBR rendering or change stored appearances. See
|
||||
The numerical module alone advertises no capabilities; the installed consumer above
|
||||
provides rendering. See
|
||||
[the model, evidence and remaining consumer gates](docs/render-surface.md).
|
||||
|
||||
- A bounded cooperative Radiance RGBE input utility is available in L1 render;
|
||||
@@ -100,8 +137,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
- 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)
|
||||
The legacy Design snapshot route remains unchanged. Later slices above provide the
|
||||
workspace/jobs and opaque PBR; HDRI 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
|
||||
@@ -264,7 +301,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
of two edges at a vertex meet in a crease, as on a box), picked together or one after the
|
||||
other, except a round after a round of the edge between the wall and a flat face that it
|
||||
would run over; the three edges at a vertex of any smooth faces, of one radius, end in a
|
||||
piece of sphere.
|
||||
piece of sphere. A limiting blend that collapses a side wall into coincident boundary
|
||||
curves is refused as too large rather than returning an unmeshable face (#1113).
|
||||
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
|
||||
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
|
||||
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
|
||||
@@ -443,6 +481,15 @@ editing. Construction planes and points resolve drawn 3D wires without applying
|
||||
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
|
||||
and reference-object placement remain separate work, as do loft rails and centerlines.
|
||||
|
||||
## Design properties (#1015)
|
||||
|
||||
- Designs carry project, part number, revision, author, organization, description and licence
|
||||
in an optional `metadata` object (File → Design Properties…, `document.metadata.*`,
|
||||
`inspect_design`). STEP and IGES headers and 3MF model metadata take the fields their format
|
||||
has a place for. Other tools keep their own data under a reverse-DNS key, kept as it is
|
||||
through open, edit and save and never exported. See [design properties](docs/book/src/guide/files.md#design-properties).
|
||||
Per-component properties (a part number per component) can follow with the same type.
|
||||
|
||||
## Document-opening performance
|
||||
|
||||
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
|
||||
@@ -488,6 +535,14 @@ and reference-object placement remain separate work, as do loft rails and center
|
||||
events by strategy and reports any bounded-log truncation. Existing geometry
|
||||
acceptance checks remain in force.
|
||||
|
||||
- Cylinders of one radius whose axes cross (a second bore across a bore of the same size, a pipe
|
||||
tee of equal pipes) touch at two points, where the mesh intersection cannot trace the curves
|
||||
and a boolean cannot tell the four arms of the crossing apart. When the ordinary boolean fails
|
||||
on such a pair, both faces are split along the line of their cylinder through the touching
|
||||
points (the solids are unchanged), the boolean is taken of those, and its history is composed
|
||||
back over the operands as given (#788). The result keeps the split lines as edges of the bore
|
||||
walls. A crossing the ordinary route fails on and the split faces do not help (a radius of 17.5
|
||||
in a 45 mm block with a tool of 80 mm) is still refused.
|
||||
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
|
||||
polylines without panicking. Direct constructor regressions retain valid open endpoints
|
||||
and closed spherical intersections; the original interactive trigger remains unidentified.
|
||||
|
||||
+171
-47
@@ -1,12 +1,12 @@
|
||||
//! SolidCraft headless command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--allow-errors]
|
||||
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
|
||||
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines
|
||||
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
|
||||
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…|NAME | --camera JSON] [--no-grid] [--no-axes]
|
||||
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solidcraft-cli commands the command registry as JSON
|
||||
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
@@ -24,6 +24,7 @@ mod crashes;
|
||||
mod fuzz;
|
||||
mod oracle;
|
||||
mod recipe;
|
||||
mod render;
|
||||
mod spatial;
|
||||
|
||||
use std::process::ExitCode;
|
||||
@@ -33,18 +34,27 @@ use serde_json::{Value, json};
|
||||
mod app_icon;
|
||||
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::render::{StandardView, render_png};
|
||||
use solidcraft_engine::render::render_png;
|
||||
use solidcraft_engine::view::Guides;
|
||||
|
||||
const USAGE: &str = "usage:
|
||||
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--allow-errors]
|
||||
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
|
||||
solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...
|
||||
(a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)
|
||||
solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect; or --in FILE)
|
||||
solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect; or --in FILE)
|
||||
(--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,
|
||||
as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)
|
||||
solidcraft-cli script <design.solidcraft|script.json> [--out script.json]
|
||||
(the design's history as a command script that eval and run replay)
|
||||
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]
|
||||
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]]
|
||||
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
|
||||
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]
|
||||
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
|
||||
(as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;
|
||||
--view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;
|
||||
--camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);
|
||||
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture)
|
||||
solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]
|
||||
solidcraft-cli exec <command> [json-params]
|
||||
solidcraft-cli commands
|
||||
solidcraft-cli recipe <recipe.json>
|
||||
@@ -63,6 +73,7 @@ fn main() -> ExitCode {
|
||||
Some("eval" | "inspect") => cmd_eval(&args[1..]),
|
||||
Some("script") => cmd_script(&args[1..]),
|
||||
Some("snapshot") => cmd_snapshot(&args[1..]),
|
||||
Some("render") => render::run(&args[1..]),
|
||||
Some("exec") => cmd_exec(&args[1..]),
|
||||
Some("commands") => {
|
||||
use std::io::Write as _;
|
||||
@@ -165,7 +176,7 @@ fn positional(args: &[String]) -> Option<&String> {
|
||||
let mut i = 0;
|
||||
while let Some(a) = args.get(i) {
|
||||
if a.starts_with("--") {
|
||||
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors") { 1 } else { 2 };
|
||||
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors" | "--no-grid" | "--no-axes") { 1 } else { 2 };
|
||||
continue;
|
||||
}
|
||||
return Some(a);
|
||||
@@ -173,32 +184,52 @@ fn positional(args: &[String]) -> Option<&String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `--json-out FILE`: where the JSON a command prints goes instead of stdout.
|
||||
fn json_out(args: &[String]) -> Result<Option<&str>, String> {
|
||||
if !args.iter().any(|a| a == "--json-out") {
|
||||
return Ok(None);
|
||||
}
|
||||
match flag(args, "--json-out") {
|
||||
Some(file) if !file.starts_with("--") => Ok(Some(file)),
|
||||
_ => Err("--json-out needs a FILE".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` as indented JSON with a final newline: on stdout, or in `file`. The file is plain UTF-8
|
||||
/// with no byte-order mark, whatever the platform's shell does to redirected output (Windows
|
||||
/// PowerShell's `>` writes UTF-16, or UTF-8 with a BOM), so strict JSON readers take it.
|
||||
fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
|
||||
match file {
|
||||
Some(file) => {
|
||||
let mut text = pretty(v);
|
||||
text.push('\n');
|
||||
std::fs::write(file, text).map_err(|e| format!("{file}: {e}"))
|
||||
}
|
||||
None => {
|
||||
println!("{}", pretty(v));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
let json_out = json_out(args)?;
|
||||
let base = flag(args, "--in");
|
||||
let path = positional(args);
|
||||
if path.is_none() && base.is_none() {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
// `--in` starts from a design or STEP file; the positional script then runs on top of it.
|
||||
let mut s = match base {
|
||||
Some(b) => load(b)?,
|
||||
None => Session::default(),
|
||||
};
|
||||
let results = if args.iter().any(|a| a == "--results") {
|
||||
let (mut s, results) = if args.iter().any(|a| a == "--results") {
|
||||
// Collect every command's result (scripts only), then process requested files too.
|
||||
let mut s = match base {
|
||||
Some(b) => load(b)?,
|
||||
None => Session::default(),
|
||||
};
|
||||
let v = read_json(path.ok_or("--results needs a script")?)?;
|
||||
let r = s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
Some(Value::Array(r))
|
||||
(s, Some(Value::Array(r)))
|
||||
} else {
|
||||
match (base, path) {
|
||||
(Some(_), Some(p)) => {
|
||||
let v = read_json(p)?;
|
||||
s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
}
|
||||
(None, Some(p)) => s = load(p)?,
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
(open(base, path)?, None)
|
||||
};
|
||||
// A feature that fails leaves the bodies as they were before it (a parameter change can
|
||||
// break a feature without failing the command that made it): writing them would hand out
|
||||
@@ -213,10 +244,45 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(results) = results {
|
||||
println!("{}", pretty(&results));
|
||||
} else if !args.iter().any(|a| a == "--quiet") {
|
||||
print_json(&results, json_out)?;
|
||||
} else if json_out.is_some() || !args.iter().any(|a| a == "--quiet") {
|
||||
// `--quiet` silences stdout; a file that was asked for is still written.
|
||||
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
|
||||
println!("{}", pretty(&m));
|
||||
print_json(&m, json_out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The session `run` and `snapshot` start from: `--in` (a design or STEP file) with the
|
||||
/// positional script run on top of it, or either one alone.
|
||||
fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
|
||||
match (base, path) {
|
||||
(Some(b), Some(p)) => {
|
||||
let mut s = load(b)?;
|
||||
s.run_script(&read_json(p)?).map_err(|e| e.to_string())?;
|
||||
Ok(s)
|
||||
}
|
||||
(Some(b), None) => load(b),
|
||||
(None, Some(p)) => load(p),
|
||||
(None, None) => Err(USAGE.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses an option `cmd` doesn't take, and one missing its value: a typo, or another
|
||||
/// command's option, would otherwise be ignored without a word.
|
||||
fn known_flags(cmd: &str, args: &[String], valued: &[&str], switches: &[&str]) -> Result<(), String> {
|
||||
let mut i = 0;
|
||||
while let Some(a) = args.get(i) {
|
||||
if valued.contains(&a.as_str()) {
|
||||
match args.get(i + 1) {
|
||||
Some(v) if !v.starts_with("--") => i += 2,
|
||||
_ => return Err(format!("{cmd}: {a} needs a value")),
|
||||
}
|
||||
} else if a.starts_with("--") && !switches.contains(&a.as_str()) {
|
||||
return Err(format!("{cmd} doesn't take {a} (it takes {})", [valued, switches].concat().join(", ")));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -235,6 +301,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn cmd_eval(args: &[String]) -> Result<(), String> {
|
||||
let json_out = json_out(args)?;
|
||||
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
|
||||
let mut s = load(path)?;
|
||||
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
|
||||
@@ -247,8 +314,7 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
|
||||
"params": d["params"],
|
||||
"sketches": d["sketches"],
|
||||
});
|
||||
println!("{}", pretty(&out));
|
||||
Ok(())
|
||||
print_json(&out, json_out)
|
||||
}
|
||||
|
||||
/// A design's history as a command script (`timeline.export_script`): printed, or written to
|
||||
@@ -269,16 +335,35 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or_else(|| USAGE.to_string())?;
|
||||
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
|
||||
let w = flag(args, "--width").and_then(|x| x.parse().ok()).unwrap_or(1280usize);
|
||||
let h = flag(args, "--height").and_then(|x| x.parse().ok()).unwrap_or(800usize);
|
||||
let s = load(path)?;
|
||||
let mut cam = solidcraft_engine::view::home_camera(&s);
|
||||
if let Some(v) = flag(args, "--view") {
|
||||
cam.set_view(StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?);
|
||||
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view", "--camera"], &["--no-grid", "--no-axes"])?;
|
||||
if positional(args).is_none() && flag(args, "--in").is_none() {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
let scene = solidcraft_engine::view::scene(&s, &cam);
|
||||
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
|
||||
let size = |name: &str, default: usize| match flag(args, name) {
|
||||
None => Ok(default),
|
||||
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
|
||||
};
|
||||
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
|
||||
let mut s = open(flag(args, "--in"), positional(args))?;
|
||||
// The camera choices of the MCP `screenshot`, through the same command (`view.apply`): a
|
||||
// standard view, a view saved in the design, or an explicit camera. A script that applies a
|
||||
// view itself sets the camera too; without any, the iso view framing the model.
|
||||
match (flag(args, "--view"), flag(args, "--camera")) {
|
||||
(Some(_), Some(_)) => return Err("snapshot: give --view or --camera, not both".into()),
|
||||
(Some(v), None) => {
|
||||
let hint = "standard views: iso, front, back, top, bottom, left, right";
|
||||
s.execute("view.apply", &json!({"view": v})).map_err(|e| format!("--view {v}: {e} ({hint}; or a view saved with view.save)"))?;
|
||||
}
|
||||
(None, Some(c)) => {
|
||||
let camera: Value = serde_json::from_str(c).map_err(|e| format!("--camera: not JSON: {e}"))?;
|
||||
s.execute("view.apply", &json!({"camera": camera})).map_err(|e| format!("--camera: {e}"))?;
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
let cam = s.camera.unwrap_or_else(|| solidcraft_engine::view::home_camera(&s));
|
||||
let guides = Guides { grid: !args.iter().any(|a| a == "--no-grid"), axes: !args.iter().any(|a| a == "--no-axes") };
|
||||
let scene = solidcraft_engine::view::scene_with_guides(&s, &cam, s.section, guides);
|
||||
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
|
||||
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
|
||||
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
|
||||
@@ -359,13 +444,20 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
|
||||
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
|
||||
(Some(addr), None) => Box::new(solidcraft_mcp::Remote::connect_with_token(addr, flag(args, "--token")).map_err(|e| {
|
||||
let port = addr.rsplit(':').next().filter(|p| p.parse::<u16>().is_ok()).unwrap_or("PORT");
|
||||
format!(
|
||||
"cannot connect to {addr}: {e}\nno SolidCraft app is listening there; start it with \
|
||||
`solidcraft --control {port}` (or SOLIDCRAFT_CONTROL_PORT={port}) and retry"
|
||||
)
|
||||
})?),
|
||||
(Some(addr), None) => {
|
||||
let Some(port) = addr.rsplit_once(':').and_then(|(_, p)| p.parse::<u16>().ok()).filter(|p| *p != 0) else {
|
||||
return Err(format!("mcp: --connect wants HOST:PORT, such as 127.0.0.1:7878, not `{addr}`"));
|
||||
};
|
||||
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
|
||||
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token"));
|
||||
if let Err(e) = remote.try_connect() {
|
||||
eprintln!(
|
||||
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
|
||||
(or SOLIDCRAFT_CONTROL_PORT={port}), tool calls connect once it runs"
|
||||
);
|
||||
}
|
||||
Box::new(remote)
|
||||
}
|
||||
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load(path)?)),
|
||||
(None, None) => Box::new(solidcraft_mcp::Headless::default()),
|
||||
};
|
||||
@@ -465,5 +557,37 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
st.warm_meshes(|| true);
|
||||
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
|
||||
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
|
||||
// `--display LEVEL`: the display plans the viewport draws at that display quality, made as
|
||||
// the GPU viewport makes them; `--edit NAME EXPR` then changes a parameter and times the
|
||||
// evaluation and the plans of the bodies it changed.
|
||||
let flag = |name: &str| args.iter().position(|a| a == name);
|
||||
let Some(level) = flag("--display").and_then(|i| args.get(i + 1)) else { return Ok(()) };
|
||||
let q = solidcraft_engine::doc::display_quality::DisplayQuality::parse(level).ok_or("--display: coarse, normal, fine or ultra")?;
|
||||
let plans = |st: &solidcraft_engine::doc::ModelState| {
|
||||
let t = std::time::Instant::now();
|
||||
solidcraft_engine::kernel::for_each_on_threads(&st.bodies, 16, |b| {
|
||||
let _ = b.plan_mesh_at(q);
|
||||
});
|
||||
let triangles: usize = st.bodies.iter().filter_map(|b| b.plan_mesh_at(q)).map(|m| m.triangles.len()).sum();
|
||||
(t.elapsed().as_secs_f64() * 1000.0, triangles)
|
||||
};
|
||||
let (ms, triangles) = plans(&st);
|
||||
println!("{}", json!({"step": "display", "quality": q.id(), "ms": ms, "triangles": triangles}));
|
||||
if let Some(i) = flag("--edit") {
|
||||
let (Some(name), Some(expr)) = (args.get(i + 1), args.get(i + 2)) else { return Err("--edit NAME EXPR".into()) };
|
||||
let mut s = s;
|
||||
let t = std::time::Instant::now();
|
||||
s.execute("parameters.change", &json!({"name": name, "expression": expr})).map_err(|e| e.to_string())?;
|
||||
let eval_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let st = s.world_state();
|
||||
let t = std::time::Instant::now();
|
||||
st.warm_meshes(|| true);
|
||||
let mesh_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let (ms, triangles) = plans(&st);
|
||||
println!(
|
||||
"{}",
|
||||
json!({"step": "edit", "quality": q.id(), "evaluate_ms": eval_ms, "mesh_ms": mesh_ms, "display_ms": ms, "triangles": triangles})
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -92,7 +92,7 @@ fn extent(f: &Value, p: &mut Value) {
|
||||
"symmetric" => {
|
||||
let full = e.get("is_full_length").and_then(Value::as_bool).unwrap_or(false);
|
||||
p["direction"] = json!("symmetric");
|
||||
p["distance"] = if full { json!(format!("({}) / 2", expr_text(&dist))) } else { dist };
|
||||
p["distance"] = if full { dist } else { json!(format!("({}) * 2", expr_text(&dist))) };
|
||||
}
|
||||
"two_sides" => {
|
||||
p["distance"] = e.get("side1").cloned().unwrap_or(Value::Null);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Complete headless image-job workflow through the same engine commands as the UI/MCP.
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::{
|
||||
doc::render_settings::RenderSetup,
|
||||
render::job::{CpuHost, JobId, Phase},
|
||||
};
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let input = args.first().filter(|p| !p.starts_with('-')).ok_or("render requires an input design or script")?;
|
||||
let mut out = None;
|
||||
let mut setup = None;
|
||||
let mut width = None;
|
||||
let mut height = None;
|
||||
let mut transparent = false;
|
||||
let mut overwrite = false;
|
||||
let mut timeout = 120u64;
|
||||
let mut i = 1;
|
||||
while let Some(arg) = args.get(i) {
|
||||
match arg.as_str() {
|
||||
"--transparent" => transparent = true,
|
||||
"--overwrite" => overwrite = true,
|
||||
"--out" | "--setup" | "--width" | "--height" | "--timeout" => {
|
||||
i += 1;
|
||||
let value = args.get(i).ok_or_else(|| format!("missing value for {arg}"))?;
|
||||
match arg.as_str() {
|
||||
"--out" => out = Some(value.clone()),
|
||||
"--setup" => setup = Some(value.clone()),
|
||||
"--width" => width = Some(value.parse::<u32>().map_err(|_| "invalid width")?),
|
||||
"--height" => height = Some(value.parse::<u32>().map_err(|_| "invalid height")?),
|
||||
_ => timeout = value.parse::<u64>().map_err(|_| "invalid timeout")?,
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("unknown render option: {arg}")),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if !(1..=3600).contains(&timeout) {
|
||||
return Err("render timeout must be 1..3600 seconds".into());
|
||||
}
|
||||
let out = out.ok_or("render requires --out FILE.png")?;
|
||||
let mut session = crate::load(input)?;
|
||||
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
|
||||
session.execute("render.scene", &json!({})).map_err(|e| e.to_string())?;
|
||||
let mut settings = if let Some(name) = setup {
|
||||
session.doc.render.setups.get(&name).cloned().ok_or("no saved render setup with that name")?
|
||||
} else {
|
||||
let mut settings = RenderSetup::default();
|
||||
if let Some(camera) = session.camera {
|
||||
settings.camera = camera;
|
||||
} else {
|
||||
settings.camera.fit(&solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?.bounds);
|
||||
}
|
||||
settings
|
||||
};
|
||||
if let Some(width) = width {
|
||||
settings.width = width;
|
||||
}
|
||||
if let Some(height) = height {
|
||||
settings.height = height;
|
||||
}
|
||||
let params = json!({"settings":settings,"transparent_background":transparent});
|
||||
let preflight = session.execute("render.preflight", ¶ms).map_err(|e| e.to_string())?;
|
||||
let started = session.execute("render.start", ¶ms).map_err(|e| e.to_string())?;
|
||||
let id: JobId = serde_json::from_value(started["job"].clone()).map_err(|e| e.to_string())?;
|
||||
let handle = session.render_job_handle(id).map_err(|e| e.to_string())?;
|
||||
let status = match handle.wait_timeout(std::time::Duration::from_secs(timeout)) {
|
||||
Ok(status) => status,
|
||||
Err(e) => {
|
||||
let _ = session.execute("render.cancel", &json!({"job":id}));
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
if status.progress.phase != Phase::Completed {
|
||||
return Err(status.error.map_or_else(|| "render cancelled".into(), |e| e.to_string()));
|
||||
}
|
||||
let exported = session.execute("render.export", &json!({"job":id,"path":out,"overwrite":overwrite})).map_err(|e| e.to_string())?;
|
||||
println!("{}", crate::pretty(&json!({"preflight":preflight,"export":exported})));
|
||||
Ok(())
|
||||
}
|
||||
@@ -13,8 +13,10 @@
|
||||
//! fails the test (a regression). Newly passing actions are reported; record them with
|
||||
//! `cargo xtask action-matrix --bless` (or `ACTION_MATRIX_BLESS=1` on [`action_matrix_all`]).
|
||||
//! Every CI run checks a fixed sample ([`action_matrix_sample`], under a minute); the full matrix
|
||||
//! is four ignored tests (`action_matrix_full_N`) that the nightly "Action matrix" job runs side by
|
||||
//! side. See docs/action-matrix.md.
|
||||
//! is the ignored [`action_matrix_shard`], which the nightly "Action matrix" jobs run one shard
|
||||
//! each (`ACTION_MATRIX_SHARD=k/n`), cut by what each design costs (`tests/action_matrix/costs.json`).
|
||||
//! [`action_matrix_determinism`] runs a sample twice and lists any action whose outcome changed.
|
||||
//! See docs/action-matrix.md.
|
||||
//!
|
||||
//! Each design runs in a child process (this test binary, [`action_matrix_worker`]) so an action
|
||||
//! that hangs or aborts is recorded as such and the rest of the design still runs.
|
||||
@@ -33,6 +35,10 @@ use solidcraft_engine::geom::Vec3;
|
||||
|
||||
/// One in this many actions runs on pull requests (by a stable hash of its key).
|
||||
const PR_SAMPLE: u64 = 40;
|
||||
/// One in this many actions runs twice in the nightly determinism check, on the designs whose
|
||||
/// whole matrix takes at most [`DETERMINISM_MAX_SECS`] on a runner (most of the corpus).
|
||||
const DETERMINISM_SAMPLE: u64 = 8;
|
||||
const DETERMINISM_MAX_SECS: f64 = 300.0;
|
||||
/// Designs that take seconds to build: the full matrix only.
|
||||
const SLOW: [&str; 7] = [
|
||||
"oracle/41-tube-radial-holes",
|
||||
@@ -623,17 +629,25 @@ fn action_matrix_worker() {
|
||||
out.flush().unwrap();
|
||||
};
|
||||
let Some((_, path)) = designs().into_iter().find(|(n, _)| *n == design) else { panic!("no design {design}") };
|
||||
// A slice of a heavy design (`j/n`): the actions whose name hashes to `j` modulo `n` (by
|
||||
// index, slices would take whole kinds of action when n shares a factor with the number of
|
||||
// actions per entity, and a slice of only fillets runs three times as long); the first
|
||||
// slice records the load.
|
||||
let slice: Option<(usize, usize)> = std::env::var("ACTION_MATRIX_SLICE").ok().and_then(|v| parse_part(&v));
|
||||
let first = slice.is_none_or(|(j, _)| j == 0);
|
||||
let t0 = Instant::now();
|
||||
let s = match load(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
emit(outcome(&design, "design", "load", &Err(("load".into(), e)), t0.elapsed().as_millis()));
|
||||
if first {
|
||||
emit(outcome(&design, "design", "load", &Err(("load".into(), e)), t0.elapsed().as_millis()));
|
||||
}
|
||||
emit(json!({"done": true}));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let before = errors(&s);
|
||||
if from == 0 {
|
||||
if from == 0 && first {
|
||||
let r = if before.is_empty() { Ok(()) } else { Err(("timeline_error".into(), before.values().cloned().collect::<Vec<_>>().join("; "))) };
|
||||
emit(outcome(&design, "design", "load", &r, t0.elapsed().as_millis()));
|
||||
}
|
||||
@@ -647,6 +661,9 @@ fn action_matrix_worker() {
|
||||
if only.as_ref().is_some_and(|o| !o.iter().any(|x| x == a.action)) {
|
||||
continue;
|
||||
}
|
||||
if slice.is_some_and(|(j, n)| stable_hash(&format!("{} {}", a.entity, a.action)) % n as u64 != j as u64) {
|
||||
continue;
|
||||
}
|
||||
// Whole families of pieces are sampled together (see [`family`]).
|
||||
let k = key(&design, &family(&a.entity), a.action);
|
||||
if !stable_hash(&k).is_multiple_of(sample) {
|
||||
@@ -708,46 +725,155 @@ fn action_matrix_worker() {
|
||||
emit(json!({"done": true, "actions": list.len()}));
|
||||
}
|
||||
|
||||
/// Run the actions (one in `sample`) of every design in part `part` of `parts`, in child
|
||||
/// processes, `jobs` at a time.
|
||||
fn run_matrix(sample: u64, part: usize, parts: usize) -> Vec<Value> {
|
||||
/// `"k/n"` (1-based `k`) as `(k - 1, n)`.
|
||||
fn parse_part(v: &str) -> Option<(usize, usize)> {
|
||||
let (k, n) = v.split_once('/')?;
|
||||
let (k, n): (usize, usize) = (k.trim().parse().ok()?, n.trim().parse().ok()?);
|
||||
(k >= 1 && k <= n).then_some((k - 1, n))
|
||||
}
|
||||
|
||||
/// What one design costs on a CI runner (`tests/action_matrix/costs.json`, measured by the
|
||||
/// nightly shards): the whole run and the load before its first action, in seconds.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Cost {
|
||||
secs: f64,
|
||||
load: f64,
|
||||
}
|
||||
|
||||
fn costs_path() -> PathBuf {
|
||||
root().join("tests/action_matrix/costs.json")
|
||||
}
|
||||
|
||||
fn read_costs() -> BTreeMap<String, Cost> {
|
||||
let Some(v) = std::fs::read(costs_path()).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()) else { return BTreeMap::new() };
|
||||
v["designs"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(d, c)| (d.clone(), Cost { secs: c["secs"].as_f64().unwrap_or(0.0), load: c["load"].as_f64().unwrap_or(0.0) }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A design new to the corpus (not in costs.json yet) is planned as this.
|
||||
const UNKNOWN_COST: Cost = Cost { secs: 120.0, load: 5.0 };
|
||||
/// A design that takes longer than this on a runner is split into slices of about this much, or
|
||||
/// five loads if that is more (each slice loads the design again), so no shard waits on one design.
|
||||
const SLICE_SECS: f64 = 600.0;
|
||||
/// Designs that take longer than this to load on a runner stay out of the pull request sample.
|
||||
const SAMPLE_MAX_LOAD_SECS: f64 = 8.0;
|
||||
|
||||
/// A piece of work: one design, or a slice `(j, n)` of a heavy one, and what it should cost.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Unit {
|
||||
design: String,
|
||||
slice: Option<(usize, usize)>,
|
||||
cost: f64,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
fn name(&self) -> String {
|
||||
match self.slice {
|
||||
Some((j, n)) => format!("{} [{}/{n}]", self.design, j + 1),
|
||||
None => self.design.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole matrix split into `parts` shards of about equal cost: heavy designs in slices, then
|
||||
/// the largest pieces first, each to the shard with the least so far. The same corpus and
|
||||
/// costs.json always give the same shards.
|
||||
fn plan(designs: &[String], costs: &BTreeMap<String, Cost>, parts: usize) -> Vec<Vec<Unit>> {
|
||||
let mut units = Vec::new();
|
||||
for d in designs {
|
||||
let c = costs.get(d).copied().unwrap_or(UNKNOWN_COST);
|
||||
let n = ((c.secs / SLICE_SECS.max(5.0 * c.load)).ceil() as usize).max(1);
|
||||
if n == 1 {
|
||||
units.push(Unit { design: d.clone(), slice: None, cost: c.secs });
|
||||
} else {
|
||||
let each = c.load + (c.secs - c.load).max(0.0) / n as f64;
|
||||
units.extend((0..n).map(|j| Unit { design: d.clone(), slice: Some((j, n)), cost: each }));
|
||||
}
|
||||
}
|
||||
units.sort_by(|a, b| b.cost.total_cmp(&a.cost).then_with(|| a.name().cmp(&b.name())));
|
||||
let mut shards: Vec<(f64, Vec<Unit>)> = vec![(0.0, Vec::new()); parts];
|
||||
for u in units {
|
||||
let i = (0..parts).min_by(|&a, &b| shards[a].0.total_cmp(&shards[b].0)).unwrap_or(0);
|
||||
shards[i].0 += u.cost;
|
||||
shards[i].1.push(u);
|
||||
}
|
||||
shards.into_iter().map(|(_, v)| v).collect()
|
||||
}
|
||||
|
||||
/// Every design's name, with `ACTION_MATRIX_FILTER` applied.
|
||||
fn design_names() -> Vec<String> {
|
||||
let designs = designs();
|
||||
assert!(designs.len() > 80, "only {} designs", designs.len());
|
||||
let dir = std::env::temp_dir().join(format!("action-matrix-{}-{part}", std::process::id()));
|
||||
let filter = std::env::var("ACTION_MATRIX_FILTER").ok();
|
||||
designs.into_iter().map(|(n, _)| n).filter(|n| filter.as_deref().is_none_or(|f| n.contains(f))).collect()
|
||||
}
|
||||
|
||||
/// Run the actions (one in `sample`) of these units in child processes, `jobs` at a time.
|
||||
///
|
||||
/// `ACTION_MATRIX_WORKDIR` keeps each design's raw outcomes there (one JSON line each, written as
|
||||
/// they come, so a run cut short still shows what it did) and `ACTION_MATRIX_TIMINGS` appends
|
||||
/// each unit's time to that file (JSON lines).
|
||||
fn run_units(sample: u64, queue: &[Unit]) -> Vec<Value> {
|
||||
let keep = std::env::var_os("ACTION_MATRIX_WORKDIR").map(PathBuf::from);
|
||||
static RUN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let run = RUN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = keep.clone().unwrap_or_else(|| std::env::temp_dir().join(format!("action-matrix-{}-{run}", std::process::id())));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let timings = std::env::var_os("ACTION_MATRIX_TIMINGS").map(PathBuf::from);
|
||||
let jobs: usize = std::env::var("ACTION_MATRIX_JOBS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or_else(|| std::thread::available_parallelism().map_or(2, |n| n.get()))
|
||||
.max(1);
|
||||
let filter = std::env::var("ACTION_MATRIX_FILTER").ok();
|
||||
let queue: Vec<(String, PathBuf)> = designs
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(i, (n, _))| i % parts == part && filter.as_deref().is_none_or(|f| n.contains(f)) && (sample == 1 || !SLOW.contains(&n.as_str())))
|
||||
.map(|(_, d)| d)
|
||||
.collect();
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let results = std::sync::Mutex::new(Vec::new());
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..jobs {
|
||||
scope.spawn(|| {
|
||||
while let Some((name, _)) = queue.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
let out = dir.join(format!("{}.jsonl", name.replace('/', "__")));
|
||||
let lines = run_design(name, &out, sample);
|
||||
while let Some(u) = queue.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
let file = u.name().replace('/', "__").replace([' ', '[', ']'], "_");
|
||||
let out = dir.join(format!("{file}.r{run}.jsonl"));
|
||||
let t = Instant::now();
|
||||
let lines = run_design(&u.design, &out, sample, u.slice);
|
||||
let secs = t.elapsed().as_secs_f64();
|
||||
let load = lines.iter().find(|o| o["entity"] == "design" && o["action"] == "load").and_then(|o| o["ms"].as_f64()).unwrap_or(0.0) / 1000.0;
|
||||
if let Some(p) = &timings {
|
||||
let line = json!({"design": u.design, "slice": u.slice.map(|(j, n)| format!("{}/{n}", j + 1)), "secs": round3(secs), "load": round3(load), "actions": lines.len()});
|
||||
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(p).unwrap();
|
||||
writeln!(f, "{line}").unwrap();
|
||||
}
|
||||
eprintln!("action matrix: {} took {secs:.1} s ({} outcomes, load {load:.1} s)", u.name(), lines.len());
|
||||
results.lock().unwrap().extend(lines);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
if keep.is_none() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
let mut v = results.into_inner().unwrap();
|
||||
v.sort_by_key(|o| key(o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or("")));
|
||||
v
|
||||
}
|
||||
|
||||
/// Run the actions (one in `sample`) of every design (the pull request sample leaves out the
|
||||
/// slow-loading ones).
|
||||
fn run_matrix(sample: u64) -> Vec<Value> {
|
||||
let costs = read_costs();
|
||||
let queue: Vec<Unit> = design_names()
|
||||
.into_iter()
|
||||
.filter(|n| sample == 1 || !(SLOW.contains(&n.as_str()) || costs.get(n).is_some_and(|c| c.load > SAMPLE_MAX_LOAD_SECS)))
|
||||
.map(|design| Unit { design, slice: None, cost: 0.0 })
|
||||
.collect();
|
||||
run_units(sample, &queue)
|
||||
}
|
||||
|
||||
/// One design in child processes, restarting after an action that hangs or aborts.
|
||||
fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
|
||||
fn run_design(design: &str, out: &Path, sample: u64, slice: Option<(usize, usize)>) -> Vec<Value> {
|
||||
let _ = std::fs::remove_file(out);
|
||||
let mut from = 0usize;
|
||||
let mut results = Vec::new();
|
||||
@@ -759,6 +885,7 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
|
||||
.env("ACTION_MATRIX_OUT", out)
|
||||
.env("ACTION_MATRIX_FROM", from.to_string())
|
||||
.env("ACTION_MATRIX_SAMPLE", sample.to_string())
|
||||
.env("ACTION_MATRIX_SLICE", slice.map(|(j, n)| format!("{}/{n}", j + 1)).unwrap_or_default())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
@@ -796,6 +923,9 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
|
||||
}
|
||||
// It stopped inside action `last_start`: record it and go on after it.
|
||||
let Some(i) = last_start else {
|
||||
if slice.is_some_and(|(j, _)| j > 0) {
|
||||
break;
|
||||
}
|
||||
extra.push(
|
||||
json!({"design": design, "entity": "design", "action": "load", "ok": false, "kind": if timed_out { "timeout" } else { "crash" },
|
||||
"message": format!("the design did not load ({status:?})")}),
|
||||
@@ -863,9 +993,12 @@ fn write_baseline(results: &[Value]) {
|
||||
m.entry(design).or_default().entry(entity).or_default().extend(actions.as_str().unwrap_or("").split_whitespace().map(str::to_string));
|
||||
}
|
||||
}
|
||||
// A timeout of an action that passed keeps it passing (see [`ratchet`]).
|
||||
let base = read_baseline();
|
||||
for o in results {
|
||||
let (design, entity, action) = (o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or(""));
|
||||
let mark = if o["ok"].as_bool() == Some(true) { "" } else { "!" };
|
||||
let kept = o["kind"] == "timeout" && base.get(&key(design, entity, action)) == Some(&true);
|
||||
let mark = if o["ok"].as_bool() == Some(true) || kept { "" } else { "!" };
|
||||
m.entry(design).or_default().entry(entity).or_default().push(format!("{mark}{action}"));
|
||||
}
|
||||
// One entity per line, sorted: small diffs when a few actions change.
|
||||
@@ -894,10 +1027,18 @@ fn ratchet(results: &[Value], full: bool) {
|
||||
// baseline, over the same names this run tried, and in this run.
|
||||
let mut fam: BTreeMap<String, (usize, usize, Vec<String>, Vec<String>)> = BTreeMap::new();
|
||||
let mut fresh = 0usize;
|
||||
// An action that passed in the baseline and now runs past ACTION_TIMEOUT is listed but not
|
||||
// judged: on a busy runner the slowest actions (40-80 s on oracle 41's tube) land either side
|
||||
// of the limit from one night to the next. An error or a crash is judged as usual.
|
||||
let mut slow = Vec::new();
|
||||
for o in results {
|
||||
let (design, entity, action) = (o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or(""));
|
||||
let k = key(design, entity, action);
|
||||
let ok = o["ok"].as_bool() == Some(true);
|
||||
if o["kind"] == "timeout" && base.get(&k) == Some(&true) {
|
||||
slow.push(k);
|
||||
continue;
|
||||
}
|
||||
let f = fam.entry(key(design, &family(entity), action)).or_default();
|
||||
match base.get(&k) {
|
||||
Some(was) => {
|
||||
@@ -934,6 +1075,14 @@ fn ratchet(results: &[Value], full: bool) {
|
||||
eprintln!("baseline written: {}", baseline_path().display());
|
||||
return;
|
||||
}
|
||||
if !slow.is_empty() {
|
||||
eprintln!(
|
||||
"{} actions that pass in the baseline ran past the {} s limit this time (not judged):\n{}",
|
||||
slow.len(),
|
||||
ACTION_TIMEOUT.as_secs(),
|
||||
slow.join("\n")
|
||||
);
|
||||
}
|
||||
if !fixed.is_empty() || fresh > 0 {
|
||||
eprintln!(
|
||||
"{} actions pass now that failed in the baseline, and {fresh} are not in it: record them with `cargo xtask action-matrix --bless`:\n{}",
|
||||
@@ -952,51 +1101,156 @@ fn ratchet(results: &[Value], full: bool) {
|
||||
/// Pull requests: a fixed sample of the matrix (one action in [`PR_SAMPLE`]) against the ratchet.
|
||||
#[test]
|
||||
fn action_matrix_sample() {
|
||||
let results = run_matrix(PR_SAMPLE, 0, 1);
|
||||
let results = run_matrix(PR_SAMPLE);
|
||||
assert!(results.len() > 300, "only {} actions ran", results.len());
|
||||
ratchet(&results, false);
|
||||
}
|
||||
|
||||
/// The whole matrix against the ratchet, in four parts that the nightly CI job runs side by side
|
||||
/// (`--run-ignored only`).
|
||||
fn full_part(part: usize) {
|
||||
let results = run_matrix(1, part, 4);
|
||||
/// One shard of the whole matrix against the ratchet: `ACTION_MATRIX_SHARD=k/n` (the nightly
|
||||
/// "Action matrix" CI jobs run the n shards side by side, `--run-ignored only`). Shards are cut by
|
||||
/// what each design costs (tests/action_matrix/costs.json), see [`plan`].
|
||||
/// `ACTION_MATRIX_SHARD_LIST=1` only prints the plan.
|
||||
#[test]
|
||||
#[ignore = "heavy: the nightly Action matrix CI jobs run it, one shard each"]
|
||||
fn action_matrix_shard() {
|
||||
let Some((k, n)) = std::env::var("ACTION_MATRIX_SHARD").ok().and_then(|v| parse_part(&v)) else { panic!("set ACTION_MATRIX_SHARD=k/n") };
|
||||
let shards = plan(&design_names(), &read_costs(), n);
|
||||
for (i, sh) in shards.iter().enumerate() {
|
||||
let total: f64 = sh.iter().map(|u| u.cost).sum();
|
||||
eprintln!("shard {}/{n}: {} pieces, about {:.0} s of work{}", i + 1, sh.len(), total, if i == k { " (this one)" } else { "" });
|
||||
}
|
||||
eprintln!("this shard: {}", shards[k].iter().map(Unit::name).collect::<Vec<_>>().join(", "));
|
||||
if std::env::var_os("ACTION_MATRIX_SHARD_LIST").is_some() {
|
||||
return;
|
||||
}
|
||||
let results = run_units(1, &shards[k]);
|
||||
ratchet(&results, false);
|
||||
}
|
||||
|
||||
/// Determinism: a sample of the matrix (one family in [`DETERMINISM_SAMPLE`], the heaviest
|
||||
/// designs left out) run twice must give the same outcome for every action. Lists each action
|
||||
/// whose outcome changed between the runs.
|
||||
#[test]
|
||||
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
|
||||
fn action_matrix_full_1() {
|
||||
full_part(0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
|
||||
fn action_matrix_full_2() {
|
||||
full_part(1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
|
||||
fn action_matrix_full_3() {
|
||||
full_part(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
|
||||
fn action_matrix_full_4() {
|
||||
full_part(3);
|
||||
#[ignore = "heavy: the nightly Action matrix determinism CI job runs it"]
|
||||
fn action_matrix_determinism() {
|
||||
let outcomes = |v: Vec<Value>| -> BTreeMap<String, String> {
|
||||
v.into_iter()
|
||||
.map(|o| {
|
||||
let k = key(o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or(""));
|
||||
let r = if o["ok"].as_bool() == Some(true) {
|
||||
"pass".to_string()
|
||||
} else {
|
||||
format!("{}: {}", o["kind"].as_str().unwrap_or(""), o["message"].as_str().unwrap_or(""))
|
||||
};
|
||||
(k, r)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let costs = read_costs();
|
||||
let queue: Vec<Unit> = design_names()
|
||||
.into_iter()
|
||||
.filter(|n| costs.get(n).copied().unwrap_or(UNKNOWN_COST).secs <= DETERMINISM_MAX_SECS)
|
||||
.map(|design| Unit { design, slice: None, cost: 0.0 })
|
||||
.collect();
|
||||
let a = outcomes(run_units(DETERMINISM_SAMPLE, &queue));
|
||||
let b = outcomes(run_units(DETERMINISM_SAMPLE, &queue));
|
||||
let keys: BTreeSet<&String> = a.keys().chain(b.keys()).collect();
|
||||
let mut changed = Vec::new();
|
||||
// Pieces that share a name can swap numbers between runs (#569): compare passes per family
|
||||
// too, and report a family whose pass count differs.
|
||||
let mut fam: BTreeMap<String, (usize, usize)> = BTreeMap::new();
|
||||
for k in &keys {
|
||||
let (x, y) = (a.get(*k).map_or("missing", String::as_str), b.get(*k).map_or("missing", String::as_str));
|
||||
let mut parts = k.splitn(3, " :: ");
|
||||
let (d, e, act) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or(""));
|
||||
let f = fam.entry(key(d, &family(e), act)).or_default();
|
||||
f.0 += usize::from(x == "pass");
|
||||
f.1 += usize::from(y == "pass");
|
||||
if x != y {
|
||||
changed.push(format!("{k}\n first: {x}\n second: {y}"));
|
||||
}
|
||||
}
|
||||
let families: Vec<String> = fam.into_iter().filter(|(_, (x, y))| x != y).map(|(k, (x, y))| format!("{k}: {x} then {y} pass")).collect();
|
||||
eprintln!(
|
||||
"action matrix determinism: {} actions, {} changed between two runs, {} families pass a different number of times",
|
||||
keys.len(),
|
||||
changed.len(),
|
||||
families.len()
|
||||
);
|
||||
for c in &changed {
|
||||
eprintln!(" {c}");
|
||||
}
|
||||
assert!(
|
||||
families.is_empty(),
|
||||
"{} families of actions pass a different number of times in two runs of the same build:\n{}",
|
||||
families.len(),
|
||||
families.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole matrix at once (`cargo xtask action-matrix`): `ACTION_MATRIX_BLESS=1` rewrites the
|
||||
/// baseline; `ACTION_MATRIX_RESULTS=file.json` keeps every outcome with its message.
|
||||
/// `ACTION_MATRIX_MERGE=DIR` runs nothing and takes the outcomes from the `results-*.json` files
|
||||
/// in DIR instead (the nightly shards' artifacts), and the timings from its `timings-*.jsonl`
|
||||
/// files, which `ACTION_MATRIX_BLESS` then also writes to tests/action_matrix/costs.json.
|
||||
#[test]
|
||||
#[ignore = "cargo xtask action-matrix runs it (CI runs it as the four action_matrix_full parts)"]
|
||||
#[ignore = "cargo xtask action-matrix runs it (CI runs it as the action_matrix_shard jobs)"]
|
||||
fn action_matrix_all() {
|
||||
let results = run_matrix(1, 0, 1);
|
||||
let Some(dir) = std::env::var_os("ACTION_MATRIX_MERGE").map(PathBuf::from) else {
|
||||
let results = run_matrix(1);
|
||||
ratchet(&results, true);
|
||||
return;
|
||||
};
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
let mut timings: Vec<Value> = Vec::new();
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).collect();
|
||||
files.sort();
|
||||
for f in files {
|
||||
let name = f.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let text = std::fs::read_to_string(&f).unwrap_or_default();
|
||||
if name.starts_with("results-") && name.ends_with(".json") {
|
||||
results.extend(serde_json::from_str::<Vec<Value>>(&text).unwrap_or_else(|e| panic!("{name}: {e}")));
|
||||
} else if name.starts_with("timings-") {
|
||||
timings.extend(text.lines().filter_map(|l| serde_json::from_str(l).ok()));
|
||||
}
|
||||
}
|
||||
let ran: BTreeSet<&str> = results.iter().filter_map(|o| o["design"].as_str()).collect();
|
||||
let missing: Vec<String> = design_names().into_iter().filter(|d| !ran.contains(d.as_str())).collect();
|
||||
assert!(missing.is_empty(), "no results for {}", missing.join(", "));
|
||||
results.sort_by_key(|o| key(o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or("")));
|
||||
if std::env::var_os("ACTION_MATRIX_BLESS").is_some() && !timings.is_empty() {
|
||||
write_costs(&timings);
|
||||
}
|
||||
ratchet(&results, true);
|
||||
}
|
||||
|
||||
/// tests/action_matrix/costs.json from the shards' timings: per design, its whole time (a sliced
|
||||
/// design's slices added up, less the loads all but one of them repeat) and its load.
|
||||
fn write_costs(timings: &[Value]) {
|
||||
let mut m: BTreeMap<String, (f64, f64, usize)> = BTreeMap::new();
|
||||
for t in timings {
|
||||
let e = m.entry(t["design"].as_str().unwrap_or("").to_string()).or_default();
|
||||
e.0 += t["secs"].as_f64().unwrap_or(0.0);
|
||||
e.1 = e.1.max(t["load"].as_f64().unwrap_or(0.0));
|
||||
e.2 += 1;
|
||||
}
|
||||
// Designs these timings don't cover keep their old costs.
|
||||
for (d, c) in read_costs() {
|
||||
m.entry(d).or_insert((c.secs, c.load, 1));
|
||||
}
|
||||
let mut s = String::from(
|
||||
"{\n \"about\": \"What each action matrix design costs on a CI runner, in seconds (secs: the whole design, load: before its first action), for cutting the nightly shards (apps/solidcraft-cli/tests/action_matrix.rs, plan). Written with the baseline by cargo xtask action-matrix --bless --from DIR.\",\n \"designs\": {\n",
|
||||
);
|
||||
let n = m.len();
|
||||
for (i, (d, (secs, load, pieces))) in m.into_iter().enumerate() {
|
||||
let secs = (secs - load * pieces.saturating_sub(1) as f64).max(load);
|
||||
s += &format!(" {}: {{\"secs\": {:.1}, \"load\": {:.1}}}{}\n", json!(d), secs, load, if i + 1 < n { "," } else { "" });
|
||||
}
|
||||
s += " }\n}\n";
|
||||
std::fs::write(costs_path(), s).unwrap();
|
||||
eprintln!("costs written: {}", costs_path().display());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The matrix's causes: one small reproduction per open issue, ignored until it is fixed (the
|
||||
// issue says how many matrix actions it accounts for). Each runs commands as the desktop sends
|
||||
@@ -1038,6 +1292,106 @@ fn a_fillet_or_chamfer_on_one_edge_ending_at_a_cylinder() {
|
||||
}
|
||||
}
|
||||
|
||||
// #1255: a round on a knuckle's cap circle (oracle/63's hinge), 0.73 wide on a ring of radius
|
||||
// 4. The run past each end of the circle followed the end tangent so far that its sections
|
||||
// wrapped round the ring and the blend was refused; it is shortened now while it still
|
||||
// crosses the end face. Before #1101 a 0.13 mm station on the leaf's top edge kept the
|
||||
// matrix's radius at 0.013, which never got that far.
|
||||
#[test]
|
||||
fn a_round_on_a_knuckle_cap_circle() {
|
||||
for (cmd, size, removed) in [("solid.fillet", "radius", (2.4, 2.8)), ("solid.chamfer", "distance", (5.6, 6.3))] {
|
||||
let mut s = load(&root().join("tests/oracle/63-hinge-knuckle/recipe.json")).unwrap();
|
||||
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate(0.001).unwrap().measure().volume).sum::<f64>();
|
||||
let before = volume(&s);
|
||||
s.execute(cmd, &json!({"edges": [[40.0, 29.837_722, 6.449_490]], size: 0.728})).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{cmd}: {kind}: {e}"));
|
||||
let st = s.model.state();
|
||||
let body = &st.bodies.first().unwrap().body;
|
||||
assert!(body.validity().is_empty(), "{cmd}: {:?}", body.validity());
|
||||
let gone = before - volume(&s);
|
||||
assert!(gone > removed.0 && gone < removed.1, "{cmd} took {gone} mm³");
|
||||
}
|
||||
}
|
||||
|
||||
// #1255: a hole 0.133 mm across on the 0.5 mm ledge a loft cut leaves in a pocket floor of a
|
||||
// 900 mm slab. The Boolean needed a retry, and its guard meshed the drill as coarsely as the
|
||||
// slab's size allows (0.4 mm, three times the drill's width): the mesh had no inside, the drill
|
||||
// measured −30272 mm³ and the right cut was refused. Volumes are measured finer than each
|
||||
// body's smallest radius now.
|
||||
#[test]
|
||||
fn a_hair_thin_hole_on_a_ledge_beside_a_loft_cut() {
|
||||
let mut s = load(&root().join("examples/regressions/loft_cut_beside_round_hole.json")).unwrap();
|
||||
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate_with(0.005, true).unwrap().measure().volume).sum::<f64>();
|
||||
let before = volume(&s);
|
||||
s.execute("solid.hole", &json!({"diameter": 0.133, "position": [357.22, -150.993_333, 730.0]})).unwrap();
|
||||
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
|
||||
// The drill goes through the 4 mm ledge and on through the slab below: 690 to 730.
|
||||
let gone = before - volume(&s);
|
||||
let expected = std::f64::consts::PI * 0.0665 * 0.0665 * 40.0;
|
||||
assert!((gone - expected).abs() < 0.2, "the hole took {gone} mm³, not {expected}");
|
||||
}
|
||||
|
||||
// #1255: a 0.8 mm hole through the side wall of the plastic part's rest. Meshed at the
|
||||
// tolerance the 200 mm drill sets, the part with its rounds, bosses and draft measured 6.8 mm³
|
||||
// more after the cut than before; ten times finer it is 1.44 mm³ less, the drill's volume
|
||||
// through the 2.9 mm wall. A volume out of bounds is measured again finer before it refuses.
|
||||
#[test]
|
||||
fn a_small_hole_through_the_wall_of_a_plastic_rest() {
|
||||
let mut s = load(&root().join("apps/solidcraft-cli/tests/corpus/modern-plastic.solidcraft")).unwrap();
|
||||
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate_with(0.002, true).unwrap().measure().volume).sum::<f64>();
|
||||
let before = volume(&s);
|
||||
s.execute("solid.hole", &json!({"diameter": 0.8, "position": [63.069_841_5, 28.333_333, 4.0]})).unwrap();
|
||||
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
|
||||
let gone = before - volume(&s);
|
||||
assert!(gone > 1.2 && gone < 1.7, "the hole took {gone} mm³");
|
||||
}
|
||||
|
||||
// #1255: a hole square to a flange bent at 45°. The drill's cap, evaluated at that angle, lay
|
||||
// 2.9e-11 mm off the plane it was made on against a bound of 2.2e-11, so the sheet didn't take
|
||||
// the hole as a cut-out of its flat pattern and the cut was refused. The sheet keeps the hole
|
||||
// and still unfolds and folds back.
|
||||
#[test]
|
||||
fn a_hole_square_to_a_45_degree_flange() {
|
||||
let mut s = load(&root().join("tests/oracle/70-sm-flange-45/recipe.json")).unwrap();
|
||||
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate_with(0.002, true).unwrap().measure().volume).sum::<f64>();
|
||||
let before = volume(&s);
|
||||
s.execute("solid.hole", &json!({"diameter": 3.448, "position": [66.666_667, -4.511_845, 4.511_845]})).unwrap();
|
||||
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
|
||||
// Through the 2.5 mm flange.
|
||||
let gone = before - volume(&s);
|
||||
let expected = std::f64::consts::PI * 1.724 * 1.724 * 2.5;
|
||||
assert!((gone - expected).abs() < 0.1, "the hole took {gone} mm³, not {expected}");
|
||||
let sheets: Vec<String> = s.model.state().sheets.iter().map(|sh| sh.body.clone()).collect();
|
||||
assert!(!sheets.is_empty());
|
||||
for body in sheets {
|
||||
for id in ["sheet.unfold", "sheet.refold"] {
|
||||
s.execute(id, &json!({"body": body})).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #1255: an axis through a corner round of the sensor enclosure's lid, then a parameter that
|
||||
// widens the lid. The round is found again by name, but its point nearest the old pick is the
|
||||
// end of its arc, on the edge it shares with the flat side, and picking again by that point
|
||||
// took the side: "the face is not a cylinder". The named face is used as found now.
|
||||
#[test]
|
||||
fn an_axis_through_a_corner_round_that_a_parameter_moves() {
|
||||
let mut s = load(&root().join("examples/acceptance/sensor_enclosure.json")).unwrap();
|
||||
s.execute("construct.axis.through_cylinder", &json!({"face": {"face": [36.242_617, 29.977_740, 27.0]}})).unwrap();
|
||||
let start = s.doc.features.len();
|
||||
s.execute("parameters.change", &json!({"name": "board_w", "expression": "(60 mm) * 1.1"})).unwrap();
|
||||
check(&s, start, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
|
||||
let st = s.model.state();
|
||||
let axis = st.construct.iter().find_map(|c| match &c.geom {
|
||||
solidcraft_engine::doc::construct::ConstructGeom::Axis { origin, dir } => Some((*origin, *dir)),
|
||||
_ => None,
|
||||
});
|
||||
let (origin, dir) = axis.expect("the axis");
|
||||
// The round's axis moved with the lid's corner, from x = 36 to 39.
|
||||
let off = origin - Vec3::new(39.0, 26.0, 0.0);
|
||||
assert!((off - dir * off.dot(dir)).len() < 1e-6, "the axis passes {origin:?} along {dir:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sketch_on_a_patterned_boss_top() {
|
||||
let mut c: Vec<Value> = rectangle(json!("XY"), [0., 0.], [60., 20.]).into();
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! `--json-out FILE` writes the JSON `run` and `eval` would print to a file the CLI makes
|
||||
//! itself. A shell's `>` can re-encode stdout (Windows PowerShell writes UTF-16, or UTF-8 with a
|
||||
//! byte-order mark), which strict JSON readers refuse; the file is plain UTF-8.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-json-out-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
/// A box, and a parameter whose comment has a character outside ASCII.
|
||||
fn script(&self) -> PathBuf {
|
||||
let path = self.0.join("box.json");
|
||||
let script = json!({"commands": [
|
||||
{"command": "parameters.add", "params": {"name": "bore", "expression": "5 mm", "comment": "\u{d8} of the bore"}},
|
||||
{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}},
|
||||
]});
|
||||
std::fs::write(&path, script.to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// A filleted box whose fillet a later parameter change makes impossible: every command
|
||||
/// succeeds, the timeline ends with a failing feature.
|
||||
fn broken_script(&self) -> PathBuf {
|
||||
let path = self.0.join("broken.json");
|
||||
let script = json!({"commands": [
|
||||
{"command": "parameters.add", "params": {"name": "r", "expression": "2 mm"}},
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[10, 0, 10]], "radius": "r"}},
|
||||
{"command": "parameters.change", "params": {"name": "r", "expression": "50 mm"}},
|
||||
]});
|
||||
std::fs::write(&path, script.to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
}
|
||||
|
||||
fn stdout_of(output: Output) -> Vec<u8> {
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
output.stdout
|
||||
}
|
||||
|
||||
/// Timings differ from one run to the next.
|
||||
fn without_timings(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(map) => {
|
||||
map.remove("ms");
|
||||
map.values_mut().for_each(without_timings);
|
||||
}
|
||||
Value::Array(items) => items.iter_mut().for_each(without_timings),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file holds what stdout carried in the same command without `--json-out`: the same JSON,
|
||||
/// laid out the same way, in UTF-8 with no byte-order mark (UTF-8's or UTF-16's), so a strict
|
||||
/// reader accepts it.
|
||||
fn assert_plain_utf8_json(file: &Path, stdout: &[u8]) -> Value {
|
||||
let bytes = std::fs::read(file).unwrap();
|
||||
assert!(matches!(bytes.first(), Some(b'{' | b'[')), "the file starts with the JSON itself, not a mark");
|
||||
assert_eq!(bytes.last(), Some(&b'\n'), "like stdout, the file ends with a newline");
|
||||
let lines = |b: &[u8]| b.iter().filter(|c| **c == b'\n').count();
|
||||
assert_eq!(lines(&bytes), lines(stdout), "laid out like stdout");
|
||||
let mut value: Value = serde_json::from_str(std::str::from_utf8(&bytes).unwrap()).unwrap();
|
||||
let mut printed: Value = serde_json::from_slice(stdout).unwrap();
|
||||
without_timings(&mut value);
|
||||
without_timings(&mut printed);
|
||||
assert_eq!(value, printed, "the file is what stdout would have carried");
|
||||
value
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_results_json_out_holds_what_stdout_would() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script();
|
||||
let file = dir.0.join("results.json");
|
||||
let on_stdout = stdout_of(cli().arg("run").arg(&script).arg("--results").output().unwrap());
|
||||
let output = cli().arg("run").arg(&script).arg("--results").arg("--json-out").arg(&file).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stdout.is_empty(), "the JSON goes to the file, not to stdout");
|
||||
let results = assert_plain_utf8_json(&file, &on_stdout);
|
||||
assert_eq!(results.as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_json_out_without_results_holds_the_measurement_even_when_quiet() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script();
|
||||
let on_stdout = stdout_of(cli().arg("run").arg(&script).output().unwrap());
|
||||
for (name, extra) in [("plain", &[][..]), ("quiet", &["--quiet"][..])] {
|
||||
let file = dir.0.join(format!("measure-{name}.json"));
|
||||
let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap();
|
||||
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stdout.is_empty(), "{name}");
|
||||
let measure = assert_plain_utf8_json(&file, &on_stdout);
|
||||
assert_eq!(measure["body_count"], 1, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_json_out_holds_what_stdout_would() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script();
|
||||
let on_stdout = stdout_of(cli().arg("eval").arg(&script).output().unwrap());
|
||||
// The option may come before the file to evaluate, and `inspect` is the same command.
|
||||
for name in ["eval", "inspect"] {
|
||||
let file = dir.0.join(format!("{name}.json"));
|
||||
let output = cli().arg(name).arg("--json-out").arg(&file).arg(&script).output().unwrap();
|
||||
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stdout.is_empty(), "{name}");
|
||||
let measured = assert_plain_utf8_json(&file, &on_stdout);
|
||||
assert_eq!(measured["body_count"], 1, "{name}");
|
||||
// A character outside ASCII is written as it is, in UTF-8.
|
||||
assert!(std::fs::read_to_string(&file).unwrap().contains("\u{d8} of the bore"), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_out_needs_a_file_and_fails_when_it_cannot_write() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script();
|
||||
for cmd in ["run", "eval"] {
|
||||
let output = cli().arg(cmd).arg(&script).arg("--json-out").output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{cmd}");
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("--json-out needs a FILE"), "{cmd}");
|
||||
assert!(output.stdout.is_empty(), "{cmd}");
|
||||
|
||||
let missing = dir.0.join("missing").join("out.json");
|
||||
let output = cli().arg(cmd).arg(&script).arg("--json-out").arg(&missing).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{cmd}");
|
||||
assert!(!output.stderr.is_empty(), "{cmd}");
|
||||
assert!(output.stdout.is_empty(), "{cmd}");
|
||||
assert!(!missing.exists(), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_json_out_is_not_written_when_a_feature_fails() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.broken_script();
|
||||
for (name, extra) in [("plain", &[][..]), ("results", &["--results"][..])] {
|
||||
let file = dir.0.join(format!("failed-{name}.json"));
|
||||
let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{name}");
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("nothing was written"), "{name}");
|
||||
assert!(!file.exists(), "{name}");
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,83 @@
|
||||
//! `mcp --connect` to a port where no app listens tells the user how to start one (#270).
|
||||
//! `mcp --connect` to a port where no app listens yet (#270): the server still starts, its tools
|
||||
//! say how to start the app, and once the app runs they reach it with that launch's token.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_without_a_listening_app_names_the_control_flag() {
|
||||
fn mcp_connect_serves_before_the_app_runs_and_reaches_it_once_it_does() {
|
||||
// A port that was free a moment ago: nothing listens on it.
|
||||
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--connect", &format!("127.0.0.1:{port}")])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
let control_dir = std::env::temp_dir().join(format!("solidcraft-mcp-connect-{}-{port}", std::process::id()));
|
||||
std::fs::create_dir_all(&control_dir).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--connect", &addr, "--allow-any-path"])
|
||||
.env("SOLIDCRAFT_CONTROL_DIR", &control_dir)
|
||||
.env_remove("SOLIDCRAFT_CONTROL_TOKEN")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(!output.status.success(), "{stderr}");
|
||||
assert!(stderr.contains("--control"), "say how to start the app with a control port: {stderr}");
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let mut ask = |id: u64, method: &str, params: Value| -> Value {
|
||||
writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap();
|
||||
let mut line = String::new();
|
||||
stdout.read_line(&mut line).unwrap();
|
||||
serde_json::from_str(&line).unwrap_or_else(|e| panic!("{e}: {line:?}"))
|
||||
};
|
||||
let measure = json!({"name": "measure", "arguments": {}});
|
||||
let text = |r: &Value| r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
|
||||
|
||||
// The server answers without the app, and its instructions say the app isn't running.
|
||||
let init = ask(1, "initialize", json!({"protocolVersion": "2025-06-18"}));
|
||||
let instructions = init["result"]["instructions"].as_str().unwrap_or_default();
|
||||
assert!(instructions.contains("not running") && instructions.contains(&format!("--control {port}")), "{instructions}");
|
||||
let tools = ask(2, "tools/list", json!({}));
|
||||
assert!(tools["result"]["tools"].as_array().is_some_and(|t| !t.is_empty()), "{tools}");
|
||||
|
||||
// A tool call says the app is not running and how to start it.
|
||||
let r = ask(3, "tools/call", measure.clone());
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
assert!(text(&r).contains("not running") && text(&r).contains(&format!("solidcraft --control {port}")), "{r}");
|
||||
|
||||
// The app starts: a token file for this launch, and its control port.
|
||||
let token = "0123456789abcdef".repeat(4);
|
||||
std::fs::write(control_dir.join(format!("{port}.token")), &token).unwrap();
|
||||
let listener = TcpListener::bind(&addr).unwrap();
|
||||
let app = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
let req: Value = serde_json::from_str(&line).unwrap();
|
||||
let reply = if req["token"] == json!(token) {
|
||||
json!({"id": req["id"], "ok": true, "result": {"bodies": [], "method": req["method"]}})
|
||||
} else {
|
||||
json!({"id": req["id"], "ok": false, "error": format!("unauthorized: token {}", req["token"])})
|
||||
};
|
||||
writeln!(&stream, "{reply}").unwrap();
|
||||
});
|
||||
let r = ask(4, "tools/call", measure);
|
||||
assert_ne!(r["result"]["isError"], true, "{r}");
|
||||
assert!(text(&r).contains("engine.execute"), "{r}");
|
||||
app.join().unwrap();
|
||||
|
||||
drop(stdin);
|
||||
assert!(child.wait().unwrap().success());
|
||||
std::fs::remove_dir_all(&control_dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_without_a_port_is_refused_at_start() {
|
||||
for addr in ["localhost", "127.0.0.1:0"] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["mcp", "--connect", addr]).stdin(Stdio::null()).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(!output.status.success(), "{addr}: {stderr}");
|
||||
assert!(stderr.contains("HOST:PORT"), "{addr}: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -132,3 +132,41 @@ fn legacy_extension_and_format_tag_still_open() {
|
||||
let reopened = solidcraft_engine::io::read_design(&std::fs::read(&resaved).unwrap()).unwrap();
|
||||
assert!(reopened.to_json().contains("\"solidcraft/"));
|
||||
}
|
||||
|
||||
/// `snapshot` starts from `--in` as `run` does (its script runs on that design), and refuses an
|
||||
/// option it doesn't take rather than ignoring it.
|
||||
#[test]
|
||||
fn snapshot_runs_its_script_on_the_in_design() {
|
||||
let dir = Scratch::new();
|
||||
let base = dir.0.join("base.solidcraft");
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
|
||||
let shifted = dir.0.join("shifted.json");
|
||||
std::fs::write(
|
||||
&shifted,
|
||||
json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4, "corner": [10, 0, 0]}}]}).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let shot = |name: &str, args: &[&std::ffi::OsStr]| {
|
||||
let png = dir.0.join(name);
|
||||
json_output(cli().arg("snapshot").args(args).arg("--out").arg(&png).args(["--width", "64", "--height", "48"]).output().unwrap());
|
||||
std::fs::read(&png).unwrap()
|
||||
};
|
||||
let alone = shot("alone.png", &[shifted.as_os_str()]);
|
||||
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
|
||||
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
|
||||
assert_ne!(alone, on_base, "the script ran on the --in design");
|
||||
assert_ne!(base_only, on_base);
|
||||
for (bad, says) in [(["--frobnicate", "1"], "doesn't take --frobnicate"), (["--width", "wide"], "--width must be 16 to 8192")] {
|
||||
let output = cli().arg("snapshot").arg(&base).arg("--out").arg(dir.0.join("x.png")).args(bad).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains(says), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown command's error suggests the id that was meant.
|
||||
#[test]
|
||||
fn unknown_command_suggests_the_closest_id() {
|
||||
let output = cli().args(["exec", "file.open"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open`?"), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! `snapshot` frames its picture like the MCP `screenshot`: from a camera, a view saved in the
|
||||
//! design or one the script applies itself, and without the ground grid and the origin's axes
|
||||
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_mcp::{Headless, Server};
|
||||
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
const SIZE: (usize, usize) = (160, 100);
|
||||
|
||||
fn camera() -> Value {
|
||||
json!({"target": [20, 0, 0], "yaw": 0.6, "pitch": 0.25, "distance": 100})
|
||||
}
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-snapshot-camera-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
/// A box that straddles Z 0 and keeps clear of the origin (its axes show beside it), with a view
|
||||
/// saved in the design, plus `more` steps.
|
||||
fn script(&self, name: &str, more: &[Value]) -> PathBuf {
|
||||
let mut steps = vec![
|
||||
json!({"command": "solid.box", "params": {"length": 60, "width": 40, "height": 30, "corner": [10, -20, -15]}}),
|
||||
json!({"command": "view.save", "params": {"name": "Detail", "camera": camera()}}),
|
||||
];
|
||||
steps.extend(more.iter().cloned());
|
||||
let path = self.0.join(name);
|
||||
std::fs::write(&path, json!({"commands": steps}).to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn run(&self, script: &Path, out: &str, extra: &[&str]) -> Output {
|
||||
let (w, h) = (SIZE.0.to_string(), SIZE.1.to_string());
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.arg("snapshot")
|
||||
.arg(script)
|
||||
.arg("--out")
|
||||
.arg(self.0.join(out))
|
||||
.args(["--width", &w, "--height", &h])
|
||||
.args(extra)
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The PNG `snapshot` writes.
|
||||
fn snapshot(&self, script: &Path, out: &str, extra: &[&str]) -> Vec<u8> {
|
||||
let output = self.run(script, out, extra);
|
||||
assert!(output.status.success(), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
let png = std::fs::read(self.0.join(out)).unwrap();
|
||||
assert!(png.starts_with(b"\x89PNG"), "{extra:?}");
|
||||
png
|
||||
}
|
||||
|
||||
/// The PNG the MCP `screenshot` tool writes for the same design.
|
||||
fn mcp(&self, script: &Path, out: &str, arguments: Value) -> Vec<u8> {
|
||||
let mut session = Session::default();
|
||||
session.run_script(&serde_json::from_slice(&std::fs::read(script).unwrap()).unwrap()).unwrap();
|
||||
let mut server = Server::new(Box::new(Headless::new(session)));
|
||||
let mut arguments = arguments;
|
||||
arguments["path"] = json!(self.0.join(out).to_string_lossy());
|
||||
arguments["width"] = json!(SIZE.0);
|
||||
arguments["height"] = json!(SIZE.1);
|
||||
let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "screenshot", "arguments": arguments}});
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&call.to_string()).unwrap()).unwrap();
|
||||
assert!(reply.get("error").is_none() && reply["result"]["isError"] != json!(true), "{reply}");
|
||||
std::fs::read(self.0.join(out)).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture of `--camera` is the picture of the MCP `screenshot` with the same camera, and
|
||||
/// not the home view the option used to be ignored for.
|
||||
#[test]
|
||||
fn a_camera_frames_the_picture_as_the_mcp_screenshot_does() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let home = dir.snapshot(&script, "home.png", &[]);
|
||||
let json = camera().to_string();
|
||||
let shot = dir.snapshot(&script, "camera.png", &["--camera", &json]);
|
||||
assert_ne!(shot, home, "--camera was ignored");
|
||||
assert_eq!(shot, dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})));
|
||||
// A perspective camera too, and a different eye gives a different picture.
|
||||
let mut near = camera();
|
||||
near["fov"] = json!(0.6);
|
||||
let perspective = dir.snapshot(&script, "perspective.png", &["--camera", &near.to_string()]);
|
||||
assert_ne!(perspective, shot);
|
||||
assert_eq!(perspective, dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})));
|
||||
}
|
||||
|
||||
/// `--view` also takes a view saved in the design, which is the camera it was saved with; a
|
||||
/// standard view still looks as it did.
|
||||
#[test]
|
||||
fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let json = camera().to_string();
|
||||
let by_camera = dir.snapshot(&script, "camera.png", &["--camera", &json]);
|
||||
let saved = dir.snapshot(&script, "saved.png", &["--view", "Detail"]);
|
||||
assert_eq!(saved, by_camera);
|
||||
assert_eq!(saved, dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})));
|
||||
// A standard view frames the model from its direction, as before: the home camera turned.
|
||||
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
|
||||
let mut s = Session::default();
|
||||
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
|
||||
let mut cam = solidcraft_engine::view::home_camera(&s);
|
||||
cam.set_view(solidcraft_engine::render::StandardView::Top);
|
||||
let scene = solidcraft_engine::view::scene(&s, &cam);
|
||||
assert_eq!(top, solidcraft_engine::render::render_png(&scene, &cam, SIZE.0, SIZE.1).unwrap());
|
||||
assert_eq!(top, dir.mcp(&script, "mcp-top.png", json!({"view": "top"})));
|
||||
assert_ne!(top, saved);
|
||||
let iso = dir.snapshot(&script, "iso.png", &["--view", "iso"]);
|
||||
assert_eq!(iso, dir.snapshot(&script, "home.png", &[]));
|
||||
assert_ne!(iso, top);
|
||||
}
|
||||
|
||||
/// A script that applies a view itself (as an MCP `execute` of `view.apply` does) sets the camera
|
||||
/// of its snapshot; `--view` and `--camera` still decide when given.
|
||||
#[test]
|
||||
fn a_script_can_carry_its_camera() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("carried.json", &[json!({"command": "view.apply", "params": {"camera": camera()}})]);
|
||||
let plain = dir.script("plain.json", &[]);
|
||||
let carried = dir.snapshot(&script, "carried.png", &[]);
|
||||
assert_eq!(carried, dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]));
|
||||
assert_ne!(carried, dir.snapshot(&plain, "home.png", &[]));
|
||||
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
|
||||
assert_eq!(top, dir.snapshot(&plain, "plain-top.png", &["--view", "top"]));
|
||||
}
|
||||
|
||||
/// The grid and the axes each go when asked, and a render without both from any side shows the
|
||||
/// model alone: an empty design is then the same blank picture from every camera.
|
||||
#[test]
|
||||
fn the_grid_and_the_axes_can_be_left_out() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let json = camera().to_string();
|
||||
for view in [&["--view", "front"][..], &["--camera", &json][..]] {
|
||||
let with = |extra: &[&str], out: &str| dir.snapshot(&script, out, &[view, extra].concat());
|
||||
let (all, no_grid, no_axes, bare) = (
|
||||
with(&[], "all.png"),
|
||||
with(&["--no-grid"], "no-grid.png"),
|
||||
with(&["--no-axes"], "no-axes.png"),
|
||||
with(&["--no-grid", "--no-axes"], "bare.png"),
|
||||
);
|
||||
let different = [&all, &no_grid, &no_axes, &bare];
|
||||
for (i, a) in different.iter().enumerate() {
|
||||
for b in &different[i + 1..] {
|
||||
assert_ne!(a, b, "{view:?}: two pictures are the same");
|
||||
}
|
||||
}
|
||||
// The same as the MCP's `grid` and `axes`.
|
||||
let mcp = |extra: Value, out: &str| {
|
||||
let mut a = match view[0] {
|
||||
"--view" => json!({"view": "front"}),
|
||||
_ => json!({"camera": camera()}),
|
||||
};
|
||||
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
dir.mcp(&script, out, a)
|
||||
};
|
||||
assert_eq!(no_grid, mcp(json!({"grid": false}), "mcp-no-grid.png"));
|
||||
assert_eq!(no_axes, mcp(json!({"axes": false}), "mcp-no-axes.png"));
|
||||
assert_eq!(bare, mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"));
|
||||
}
|
||||
let empty = dir.0.join("empty.json");
|
||||
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
|
||||
let blank = |view: &str, out: &str| dir.snapshot(&empty, out, &["--view", view, "--no-grid", "--no-axes"]);
|
||||
assert_eq!(blank("top", "blank-top.png"), blank("front", "blank-front.png"));
|
||||
assert_ne!(blank("top", "blank-top.png"), dir.snapshot(&empty, "top.png", &["--view", "top"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_view_or_a_camera_that_cannot_be_used_is_an_error_and_writes_nothing() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let cases: [(&[&str], &str); 7] = [
|
||||
(&["--view", "Nowhere"], "Nowhere"),
|
||||
(&["--view", "iso", "--camera", "{}"], "not both"),
|
||||
(&["--camera", "not json"], "--camera"),
|
||||
(&["--camera", "{\"target\": [0, 0, 0]}"], "yaw"),
|
||||
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 0}"], "distance"),
|
||||
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 50, \"fov\": -1}"], "camera"),
|
||||
(&["--camera", "[1, 2, 3]"], "camera"),
|
||||
];
|
||||
for (extra, expect) in cases {
|
||||
let output = dir.run(&script, "never.png", extra);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
|
||||
assert!(stderr.contains(expect), "{extra:?}: {stderr}");
|
||||
assert!(output.stdout.is_empty() && !dir.0.join("never.png").exists(), "{extra:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The usage text says what the new options do.
|
||||
#[test]
|
||||
fn the_usage_names_the_camera_and_the_guides() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("snapshot").output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
for word in ["--camera", "--view", "view.save", "--no-grid", "--no-axes"] {
|
||||
assert!(stderr.contains(word), "usage lacks {word}: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -465,6 +465,9 @@ mod web {
|
||||
app.session.mark_saved();
|
||||
}
|
||||
if !query().contains("sample") {
|
||||
// The empty design takes Units of new designs (#1205). The sample
|
||||
// part is built in millimetres and is left that way.
|
||||
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ license.workspace = true
|
||||
publish = false
|
||||
default-run = "solidcraft"
|
||||
|
||||
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/open_documents.rs can
|
||||
# allow it (macOS file-open events have no safe API). Keep the rest in sync with [workspace.lints].
|
||||
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/open_documents.rs
|
||||
# (macOS file-open events) and src/native_crash.rs (Windows exception filter) can allow it: neither
|
||||
# has a safe API. Keep the rest in sync with [workspace.lints].
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
@@ -29,6 +30,8 @@ log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
# Checks eframe's settings file (app.ron) before eframe reads it (src/startup.rs).
|
||||
ron = "0.12"
|
||||
# Waits for wgpu's adapter list when choosing the graphics backend before the window opens.
|
||||
pollster = { workspace = true }
|
||||
getrandom = "0.3"
|
||||
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
|
||||
@@ -39,6 +42,18 @@ quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
# Native crash capture (src/native_crash.rs): the exception filter, the faulting module, a minidump.
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Kernel",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
|
||||
@@ -50,8 +65,5 @@ block2 = "0.6"
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pollster = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource = "3.0.11"
|
||||
+75
-10
@@ -19,6 +19,8 @@
|
||||
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
|
||||
mod app_icon;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_crash;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
@@ -32,14 +34,24 @@ struct App {
|
||||
solve: SolveApp,
|
||||
/// The settings folder whose start marker the first frame clears (`startup::started`).
|
||||
starting: Option<std::path::PathBuf>,
|
||||
/// Frames begun; the second one means the first was drawn (startup is over).
|
||||
frames: u32,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if let Some(dir) = self.starting.take() {
|
||||
startup::started(&dir);
|
||||
if self.frames < 2 {
|
||||
self.frames += 1;
|
||||
// A second frame right away, which means the first was presented: the app is up.
|
||||
ctx.request_repaint();
|
||||
if self.frames == 2 {
|
||||
solidcraft_engine::crash::phase(solidcraft_engine::crash::RUNNING);
|
||||
if let Some(dir) = self.starting.take() {
|
||||
startup::started(&dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The design on screen is the one whose commands a crash report replays.
|
||||
self.solve.session.crash_journal = solidcraft_engine::crash::enabled();
|
||||
@@ -86,6 +98,7 @@ impl eframe::App for App {
|
||||
fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
|
||||
let open_parent = parent.clone();
|
||||
let program_parent = parent.clone();
|
||||
Services {
|
||||
pick_open: Some(Box::new(move || {
|
||||
let future = open_parent
|
||||
@@ -119,6 +132,12 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
.pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
pick_program: Some(Box::new(move || {
|
||||
let dialog = program_parent.clone().set_title("Choose your slicer");
|
||||
let dialog = if cfg!(target_os = "windows") { dialog.add_filter("Programs", &["exe"]) } else { dialog };
|
||||
let future = dialog.pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
pick_save: Some(Box::new(move |name: &str, exts: &[&str]| {
|
||||
let dialog = parent.clone().set_file_name(name);
|
||||
let dialog = if exts == [io::DESIGN_EXT, "json"] {
|
||||
@@ -276,11 +295,19 @@ fn adopt_settings(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `graphics.json`, read before the window opens.
|
||||
fn graphics() -> Option<serde_json::Value> {
|
||||
graphics_path().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
}
|
||||
|
||||
/// The graphics backend preference (`graphics.json`'s `backend`: `auto`, `dx12`, `vulkan`, `gl`).
|
||||
fn backend_preference() -> Option<String> {
|
||||
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
|
||||
}
|
||||
|
||||
/// Anti-aliasing samples from Preferences (4 when unset).
|
||||
fn msaa() -> u16 {
|
||||
graphics_path()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
graphics()
|
||||
.and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64))
|
||||
.and_then(|m| u16::try_from(m).ok())
|
||||
.filter(|m| [1, 2, 4, 8].contains(m))
|
||||
@@ -384,6 +411,12 @@ fn main() -> eframe::Result {
|
||||
solidcraft_engine::crash::install(dir, build)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// A crash in native code (a graphics driver) says where it happened: the exception and the
|
||||
// module on Windows, and on every system the startup step the app was in.
|
||||
#[cfg(target_os = "windows")]
|
||||
native_crash::install();
|
||||
eframe::egui_wgpu::set_startup_hook(startup_step);
|
||||
solidcraft_engine::crash::phase("window");
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: app_viewport()?,
|
||||
depth_buffer: DEPTH_BITS,
|
||||
@@ -399,7 +432,7 @@ fn main() -> eframe::Result {
|
||||
settings.as_ref().map(|d| d.join("app.ron"))
|
||||
},
|
||||
persist_window: !safe_mode,
|
||||
wgpu_options: wgpu_options(cpu_renderer),
|
||||
wgpu_options: wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref()),
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
@@ -427,8 +460,10 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
created_in_app.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
solidcraft_engine::crash::phase("app setup");
|
||||
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let mut app = SolveApp::new(Session::default(), services(cc));
|
||||
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.cpu_meshing = safe_mode;
|
||||
@@ -437,7 +472,12 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
||||
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
|
||||
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
|
||||
// The graphics driver is loaded now; it may have put its own exception filter
|
||||
// in front of ours.
|
||||
#[cfg(target_os = "windows")]
|
||||
native_crash::reinstall();
|
||||
// A saved anti-aliasing the device can't do: back to the default, with a notice.
|
||||
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
|
||||
let info = rs.adapter.get_info();
|
||||
@@ -467,6 +507,11 @@ fn main() -> eframe::Result {
|
||||
for f in &files {
|
||||
app.open_path_async(f);
|
||||
}
|
||||
// No file and no sample: the empty design takes Units of new designs (#1205).
|
||||
// An opened file and the sample part keep the units they were saved or built with.
|
||||
if files.is_empty() && !sample {
|
||||
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
|
||||
}
|
||||
// No design given: the start page (not when driven over the control channel, whose
|
||||
// clients expect the design view; `ui.home` shows it there).
|
||||
if files.is_empty() && !sample && control_port.is_none() {
|
||||
@@ -489,9 +534,11 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
solidcraft_engine::crash::phase("first frame");
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
starting,
|
||||
frames: 0,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
space_mouse,
|
||||
}))
|
||||
@@ -504,11 +551,29 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe).
|
||||
fn wgpu_options(cpu: bool) -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
/// egui-wgpu is about to start a step of getting the graphics device and the window's surface up:
|
||||
/// the crash journal keeps it, and the adapter once it is picked.
|
||||
fn startup_step(step: &'static str, detail: &str) {
|
||||
if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN {
|
||||
solidcraft_engine::crash::set_environment(&format!("Renderer: {detail}"));
|
||||
}
|
||||
solidcraft_engine::crash::phase(step);
|
||||
}
|
||||
|
||||
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe). The
|
||||
/// backends come from `startup::choose_backends` (on Windows D3D12 first, and only the backend
|
||||
/// in use is started); `preference` is `graphics.json`'s `backend`.
|
||||
fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
|
||||
if cpu && let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
|
||||
setup.native_adapter_selector = Some(std::sync::Arc::new(startup::pick_cpu_adapter));
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
|
||||
let env_set = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
|
||||
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
|
||||
eprintln!("SolidCraft graphics: starting {backends:?}");
|
||||
setup.instance_descriptor.backends = backends;
|
||||
}
|
||||
if cpu {
|
||||
setup.native_adapter_selector = Some(std::sync::Arc::new(startup::pick_cpu_adapter));
|
||||
}
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Windows: what a crash in native code leaves behind (docs/crash-reports.md).
|
||||
//!
|
||||
//! A crash inside a graphics driver or another DLL (an access violation, a stack overflow) is no
|
||||
//! Rust panic: the panic hook never runs and the next start only found "an unclean exit". An
|
||||
//! unhandled-exception filter now writes, before the process goes, a small JSON file with the
|
||||
//! exception code, the faulting module and the offset into it, and a minidump, next to the run's
|
||||
//! crash journal (`solidcraft_engine::crash::native_paths`). The next start puts both into the
|
||||
//! unclean exit's report, with the startup step the journal says the app was in.
|
||||
//!
|
||||
//! Installing an exception filter and asking the system which module holds an address have no
|
||||
//! safe API, hence the `unsafe_code` allowance on this module (the app crate denies it; see
|
||||
//! `open_documents.rs` for the other one). The filter runs on a thread that may have a broken
|
||||
//! heap or almost no stack left: it doesn't allocate, formats into a buffer on the stack, and
|
||||
//! skips the minidump after a stack overflow.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_WRITE, HANDLE, HMODULE, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::Storage::FileSystem::{CREATE_ALWAYS, CreateFileW, FILE_ATTRIBUTE_NORMAL, WriteFile};
|
||||
use windows_sys::Win32::System::Diagnostics::Debug::{
|
||||
EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS, MINIDUMP_EXCEPTION_INFORMATION, MiniDumpNormal, MiniDumpWithThreadInfo,
|
||||
MiniDumpWithUnloadedModules, MiniDumpWriteDump, SetUnhandledExceptionFilter,
|
||||
};
|
||||
use windows_sys::Win32::System::LibraryLoader::{
|
||||
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, GetModuleFileNameW, GetModuleHandleExW,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId};
|
||||
|
||||
/// Where the filter writes (NUL-terminated UTF-16), set once by [`install`].
|
||||
struct Paths {
|
||||
info: Vec<u16>,
|
||||
dump: Vec<u16>,
|
||||
}
|
||||
|
||||
static PATHS: OnceLock<Paths> = OnceLock::new();
|
||||
/// The filter that was installed before ours (called after ours), as an address.
|
||||
static PREVIOUS: AtomicUsize = AtomicUsize::new(0);
|
||||
/// The filter has started writing (a second exception while it does is left alone).
|
||||
static BUSY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
const STATUS_ACCESS_VIOLATION: u32 = 0xC000_0005;
|
||||
const STATUS_IN_PAGE_ERROR: u32 = 0xC000_0006;
|
||||
const STATUS_STACK_OVERFLOW: u32 = 0xC000_00FD;
|
||||
|
||||
/// Catch native crashes from now on (after `solidcraft_engine::crash::install`; nothing without it).
|
||||
pub fn install() {
|
||||
let Some((info, dump)) = solidcraft_engine::crash::native_paths() else { return };
|
||||
let wide = |p: &Path| p.as_os_str().encode_wide().chain([0]).collect::<Vec<u16>>();
|
||||
if PATHS.set(Paths { info: wide(&info), dump: wide(&dump) }).is_ok() {
|
||||
set_filter();
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the filter back on top: a DLL loaded since (a graphics driver) may have set its own,
|
||||
/// which is then called after ours.
|
||||
pub fn reinstall() {
|
||||
if PATHS.get().is_some() {
|
||||
set_filter();
|
||||
}
|
||||
}
|
||||
|
||||
fn set_filter() {
|
||||
// SAFETY: `filter` has the signature the system calls it with and stays valid for the
|
||||
// process's life.
|
||||
let previous = unsafe { SetUnhandledExceptionFilter(Some(filter)) };
|
||||
if let Some(p) = previous
|
||||
&& p as usize != filter as *const () as usize
|
||||
{
|
||||
PREVIOUS.store(p as usize, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// The unhandled-exception filter: record, then let the previous filter (or the system) go on.
|
||||
unsafe extern "system" fn filter(pointers: *const EXCEPTION_POINTERS) -> i32 {
|
||||
if !BUSY.swap(true, Ordering::SeqCst) {
|
||||
// SAFETY: the system passes valid exception pointers for the duration of the call.
|
||||
unsafe { record(pointers) };
|
||||
}
|
||||
let previous = PREVIOUS.load(Ordering::SeqCst);
|
||||
if previous != 0 && previous != filter as *const () as usize {
|
||||
// SAFETY: the address came from `SetUnhandledExceptionFilter` as a filter of this type.
|
||||
let previous: unsafe extern "system" fn(*const EXCEPTION_POINTERS) -> i32 = unsafe { std::mem::transmute(previous) };
|
||||
// SAFETY: as for this filter.
|
||||
return unsafe { previous(pointers) };
|
||||
}
|
||||
EXCEPTION_CONTINUE_SEARCH
|
||||
}
|
||||
|
||||
/// A fixed buffer to format into without allocating (what doesn't fit is cut off).
|
||||
struct Buf {
|
||||
bytes: [u8; 2048],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl std::fmt::Write for Buf {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
let room = self.bytes.len().saturating_sub(self.len);
|
||||
let n = s.len().min(room);
|
||||
if let (Some(to), Some(from)) = (self.bytes.get_mut(self.len..self.len + n), s.as_bytes().get(..n)) {
|
||||
to.copy_from_slice(from);
|
||||
self.len += n;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The usual names of the exception codes a crash shows.
|
||||
fn code_name(code: u32) -> &'static str {
|
||||
match code {
|
||||
STATUS_ACCESS_VIOLATION => "EXCEPTION_ACCESS_VIOLATION",
|
||||
STATUS_IN_PAGE_ERROR => "EXCEPTION_IN_PAGE_ERROR",
|
||||
STATUS_STACK_OVERFLOW => "EXCEPTION_STACK_OVERFLOW",
|
||||
0xC000_001D => "EXCEPTION_ILLEGAL_INSTRUCTION",
|
||||
0xC000_0094 => "EXCEPTION_INT_DIVIDE_BY_ZERO",
|
||||
0xC000_0096 => "EXCEPTION_PRIV_INSTRUCTION",
|
||||
0xC000_008C => "EXCEPTION_ARRAY_BOUNDS_EXCEEDED",
|
||||
0xC000_0374 => "STATUS_HEAP_CORRUPTION",
|
||||
0xC000_0409 => "STATUS_STACK_BUFFER_OVERRUN",
|
||||
0xC000_0602 => "STATUS_FAIL_FAST_EXCEPTION",
|
||||
0x8000_0003 => "EXCEPTION_BREAKPOINT",
|
||||
0xE06D_7363 => "C++ exception",
|
||||
_ => "exception",
|
||||
}
|
||||
}
|
||||
|
||||
/// Write what `pointers` says into the info file, then the minidump.
|
||||
unsafe fn record(pointers: *const EXCEPTION_POINTERS) {
|
||||
let Some(paths) = PATHS.get() else { return };
|
||||
if pointers.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: checked non-null; the system keeps it valid during the filter.
|
||||
let rec = unsafe { (*pointers).ExceptionRecord };
|
||||
if rec.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: as above.
|
||||
let rec = unsafe { &*rec };
|
||||
let code = rec.ExceptionCode as u32;
|
||||
let address = rec.ExceptionAddress as usize;
|
||||
let mut module: HMODULE = std::ptr::null_mut();
|
||||
let mut name = [0u16; 512];
|
||||
// SAFETY: FROM_ADDRESS takes the address in place of a name; UNCHANGED_REFCOUNT means no
|
||||
// handle to release; `name` is as long as we say.
|
||||
let len = unsafe {
|
||||
if GetModuleHandleExW(
|
||||
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
address as *const u16,
|
||||
&mut module,
|
||||
) != 0
|
||||
{
|
||||
GetModuleFileNameW(module, name.as_mut_ptr(), name.len() as u32) as usize
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
// SAFETY: no preconditions.
|
||||
let thread = unsafe { GetCurrentThreadId() };
|
||||
let mut b = Buf { bytes: [0; 2048], len: 0 };
|
||||
let _ = write!(b, "{{\"code\":\"0x{code:08X}\",\"name\":\"{}\",\"address\":\"0x{address:016X}\",\"thread\":{thread}", code_name(code));
|
||||
if len > 0 {
|
||||
let _ = write!(b, ",\"offset\":\"0x{:X}\",\"module\":\"", address.wrapping_sub(module as usize));
|
||||
for c in char::decode_utf16(name.iter().take(len).copied()) {
|
||||
let _ = match c.unwrap_or(char::REPLACEMENT_CHARACTER) {
|
||||
'"' => b.write_str("\\\""),
|
||||
'\\' => b.write_str("\\\\"),
|
||||
c if (c as u32) < 0x20 => b.write_str("?"),
|
||||
c => b.write_char(c),
|
||||
};
|
||||
}
|
||||
let _ = b.write_str("\"");
|
||||
}
|
||||
if matches!(code, STATUS_ACCESS_VIOLATION | STATUS_IN_PAGE_ERROR) && rec.NumberParameters >= 2 {
|
||||
let what = match rec.ExceptionInformation[0] {
|
||||
0 => "read of",
|
||||
1 => "write to",
|
||||
8 => "execution at",
|
||||
_ => "access to",
|
||||
};
|
||||
let _ = write!(b, ",\"detail\":\"{what} 0x{:016X}\"", rec.ExceptionInformation[1]);
|
||||
}
|
||||
let _ = b.write_str("}");
|
||||
let bytes = b.bytes.get(..b.len).unwrap_or_default();
|
||||
// SAFETY: NUL-terminated path; the handle is closed below.
|
||||
unsafe {
|
||||
let file = create(&paths.info);
|
||||
if !file.is_null() {
|
||||
let mut written = 0u32;
|
||||
WriteFile(file, bytes.as_ptr(), bytes.len() as u32, &mut written, std::ptr::null_mut());
|
||||
CloseHandle(file);
|
||||
}
|
||||
}
|
||||
// After a stack overflow there is no stack left for writing a minidump.
|
||||
if code == STATUS_STACK_OVERFLOW {
|
||||
return;
|
||||
}
|
||||
// SAFETY: as above; the exception information points at this thread's exception.
|
||||
unsafe {
|
||||
let file = create(&paths.dump);
|
||||
if !file.is_null() {
|
||||
let exception = MINIDUMP_EXCEPTION_INFORMATION { ThreadId: thread, ExceptionPointers: pointers as *mut _, ClientPointers: 0 };
|
||||
MiniDumpWriteDump(
|
||||
GetCurrentProcess(),
|
||||
GetCurrentProcessId(),
|
||||
file,
|
||||
MiniDumpNormal | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules,
|
||||
&exception,
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
);
|
||||
CloseHandle(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A new file at `path` (NUL-terminated) for writing, or null.
|
||||
unsafe fn create(path: &[u16]) -> HANDLE {
|
||||
// SAFETY: the caller passes a NUL-terminated path.
|
||||
let h = unsafe { CreateFileW(path.as_ptr(), GENERIC_WRITE, 0, std::ptr::null(), CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, std::ptr::null_mut()) };
|
||||
if h == INVALID_HANDLE_VALUE { std::ptr::null_mut() } else { h }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The child half: install crash reporting and the filter, then fault in native code.
|
||||
#[test]
|
||||
fn child() {
|
||||
let Some(dir) = std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_CHILD") else { return };
|
||||
let build = solidcraft_engine::crash::Build { version: "9.9.9", commit: "abc1234", built: "2026-10-14" };
|
||||
let fresh = solidcraft_engine::crash::install(PathBuf::from(dir), build);
|
||||
println!("FRESH {}", serde_json::to_string(&fresh).unwrap_or_default());
|
||||
if std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_FAULT").is_none() {
|
||||
solidcraft_engine::crash::end_session();
|
||||
return;
|
||||
}
|
||||
super::install();
|
||||
solidcraft_engine::crash::phase("device creation");
|
||||
// SAFETY: none; this is the crash under test (a read of address 16, as a null struct
|
||||
// pointer's field would be).
|
||||
let v = unsafe { std::ptr::read_volatile(16 as *const u64) };
|
||||
println!("unreachable {v}");
|
||||
}
|
||||
|
||||
fn run(dir: &std::path::Path, fault: bool) -> std::process::Output {
|
||||
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
c.args(["--exact", "native_crash::tests::child", "--test-threads=1", "--nocapture"]).env("SOLIDCRAFT_NATIVE_CRASH_CHILD", dir);
|
||||
if fault {
|
||||
c.env("SOLIDCRAFT_NATIVE_CRASH_FAULT", "1");
|
||||
}
|
||||
c.output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_access_violation_is_reported_with_its_module_step_and_minidump() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-native-crash-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let out = run(&dir, true);
|
||||
assert_eq!(out.status.code().map(|c| c as u32), Some(super::STATUS_ACCESS_VIOLATION), "{out:?}");
|
||||
let sessions: Vec<PathBuf> = std::fs::read_dir(dir.join("sessions")).unwrap().flatten().map(|e| e.path()).collect();
|
||||
let info = sessions.iter().find(|p| p.to_string_lossy().ends_with(".native.json")).expect("the filter wrote what it caught");
|
||||
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(info).unwrap()).unwrap();
|
||||
assert_eq!(v["code"], "0xC0000005", "{v}");
|
||||
assert_eq!(v["detail"], "read of 0x0000000000000010", "{v}");
|
||||
assert!(v["module"].as_str().unwrap().to_lowercase().contains("solidcraft"), "the fault is in the test binary: {v}");
|
||||
let dump = sessions.iter().find(|p| p.extension().is_some_and(|e| e == "dmp")).expect("a minidump");
|
||||
assert_eq!(std::fs::read(dump).unwrap().get(..4), Some(&b"MDMP"[..]));
|
||||
// The next start reports it.
|
||||
let out = run(&dir, false);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let reports = solidcraft_engine::crash::list(&dir);
|
||||
assert_eq!(reports.len(), 1, "{reports:?}");
|
||||
let r = solidcraft_engine::crash::read(&dir, &reports[0].id).unwrap();
|
||||
assert!(r["message"].as_str().unwrap().contains("EXCEPTION_ACCESS_VIOLATION (0xC0000005) in solidcraft"), "{r}");
|
||||
assert!(r["message"].as_str().unwrap().contains("during startup (device creation"), "{r}");
|
||||
assert!(r.get("minidump_path").is_some(), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,21 @@ impl Default for DriverMapping {
|
||||
}
|
||||
}
|
||||
|
||||
impl DriverMapping {
|
||||
/// The mapping used when no 3DxWare profile is found. On Linux there is no 3DxWare, so it is
|
||||
/// what the driver applies by default in object mode (#1172): the physical Ry and Rz swapped
|
||||
/// and reversed, so a tilt left/right rolls and a twist spins. Elsewhere the driver writes a
|
||||
/// profile, and the raw axes are used until it does.
|
||||
fn without_profile() -> Self {
|
||||
let mut mapping = Self::default();
|
||||
if cfg!(target_os = "linux") {
|
||||
mapping.axes[4] = AxisRule { output: 5, enabled: true, reversed: true, scale: 1.0 };
|
||||
mapping.axes[5] = AxisRule { output: 4, enabled: true, reversed: true, scale: 1.0 };
|
||||
}
|
||||
mapping
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Motion {
|
||||
translation: [f64; 3],
|
||||
@@ -377,7 +392,12 @@ impl SpaceMouse {
|
||||
if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
log::warn!("3D mouse: reader could not start: {error}");
|
||||
}
|
||||
Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_default(), mapping_checked_at: now }
|
||||
Self {
|
||||
shared,
|
||||
last_frame: now,
|
||||
driver_mapping: load_driver_mapping().unwrap_or_else(DriverMapping::without_profile),
|
||||
mapping_checked_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
|
||||
@@ -385,7 +405,7 @@ impl SpaceMouse {
|
||||
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
|
||||
self.last_frame = now;
|
||||
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
|
||||
let updated = load_driver_mapping().unwrap_or_default();
|
||||
let updated = load_driver_mapping().unwrap_or_else(DriverMapping::without_profile);
|
||||
if updated != self.driver_mapping {
|
||||
log::info!("3D mouse: applying updated 3DxWare axis profile");
|
||||
self.driver_mapping = updated;
|
||||
@@ -580,6 +600,51 @@ mod tests {
|
||||
assert_eq!(motion.rotation, [1.0, -1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_profile_linux_uses_the_drivers_object_mode_axes() {
|
||||
// 3DxWare's object-mode mapping, as a profile (#1172: confirmed on a SpaceMouse Compact).
|
||||
let object_mode = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID></Input><Output><ActionID>HIDMultiAxis_X</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID></Input><Output><ActionID>HIDMultiAxis_Y</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID></Input><Output><ActionID>HIDMultiAxis_Z</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID></Input><Output><ActionID>HIDMultiAxis_Rx</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let profile = parse_driver_mapping(object_mode).expect("valid 3DxWare profile");
|
||||
if cfg!(target_os = "linux") {
|
||||
assert_eq!(DriverMapping::without_profile(), profile);
|
||||
// Tilting the cap rolls the view and twisting it spins it, as with the driver.
|
||||
let before = Camera::default();
|
||||
let mut tilt = before;
|
||||
navigate(
|
||||
&mut tilt,
|
||||
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }),
|
||||
0.02,
|
||||
600.0,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!((tilt.yaw, tilt.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(tilt.roll, before.roll);
|
||||
let mut twist = before;
|
||||
navigate(
|
||||
&mut twist,
|
||||
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }),
|
||||
0.02,
|
||||
600.0,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_ne!(twist.yaw, before.yaw);
|
||||
assert_eq!(twist.roll, before.roll);
|
||||
} else {
|
||||
assert_eq!(DriverMapping::without_profile(), DriverMapping::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
|
||||
let profile = br#"
|
||||
|
||||
@@ -110,6 +110,68 @@ pub fn pick_cpu_adapter(adapters: &[eframe::wgpu::Adapter], surface: Option<&efr
|
||||
})
|
||||
}
|
||||
|
||||
/// `WGPU_BACKEND`: when set, wgpu picks the backends from it and the order below is not used.
|
||||
pub const WGPU_BACKEND_ENV: &str = "WGPU_BACKEND";
|
||||
|
||||
/// The graphics backends to start with, or `None` to leave it to wgpu (`WGPU_BACKEND`, or all
|
||||
/// the backends it was built with).
|
||||
///
|
||||
/// On Windows, D3D12 comes first, then Vulkan, then OpenGL, and only the first one with a
|
||||
/// hardware adapter is started at all. wgpu would otherwise start Vulkan, D3D12 and OpenGL
|
||||
/// together and prefer Vulkan, and starting Vulkan loads every installed Vulkan driver: Intel's
|
||||
/// (`igvk64.dll`) crashed SolidCraft at startup that way, inside the driver, before anything
|
||||
/// could be reported. D3D12 is Windows' own graphics API, the one its drivers are tested with.
|
||||
/// `has_hardware(backends)` says whether those backends have an adapter that isn't software
|
||||
/// (it is only asked about the backends being tried, one at a time).
|
||||
///
|
||||
/// - `WGPU_BACKEND` set (`env_set`): `None`, whatever it says wins.
|
||||
/// - The software renderer (`cpu`, safe mode): D3D12 alone on Windows (WARP); `None` elsewhere.
|
||||
/// - `preference` (`graphics.json`'s `backend`: `dx12`, `vulkan`, `gl` or a comma list; `auto`
|
||||
/// or empty for the order above): those backends, on any system.
|
||||
/// - Elsewhere than Windows: `None`.
|
||||
/// - No hardware adapter at all: D3D12 (WARP, Windows' software renderer).
|
||||
pub fn choose_backends(
|
||||
windows: bool,
|
||||
env_set: bool,
|
||||
cpu: bool,
|
||||
preference: Option<&str>,
|
||||
mut has_hardware: impl FnMut(eframe::wgpu::Backends) -> bool,
|
||||
) -> Option<eframe::wgpu::Backends> {
|
||||
use eframe::wgpu::Backends;
|
||||
if env_set {
|
||||
return None;
|
||||
}
|
||||
if cpu {
|
||||
return windows.then_some(Backends::DX12);
|
||||
}
|
||||
if let Some(p) = preference.map(str::trim).filter(|p| !p.is_empty() && !p.eq_ignore_ascii_case("auto")) {
|
||||
let chosen = Backends::from_comma_list(p);
|
||||
if !chosen.is_empty() {
|
||||
return Some(chosen);
|
||||
}
|
||||
log::warn!("graphics: unknown backend preference `{p}`; using the default order");
|
||||
}
|
||||
if !windows {
|
||||
return None;
|
||||
}
|
||||
Some(WINDOWS_ORDER.into_iter().find(|b| has_hardware(*b)).unwrap_or(Backends::DX12))
|
||||
}
|
||||
|
||||
/// The order backends are tried in on Windows.
|
||||
pub const WINDOWS_ORDER: [eframe::wgpu::Backends; 3] = [eframe::wgpu::Backends::DX12, eframe::wgpu::Backends::VULKAN, eframe::wgpu::Backends::GL];
|
||||
|
||||
/// Whether `backends` (started on their own, nothing else) have a hardware adapter.
|
||||
pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
|
||||
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
desc.backends = backends;
|
||||
desc.flags = eframe::wgpu::InstanceFlags::from_build_config().with_env();
|
||||
let instance = eframe::wgpu::Instance::new(desc);
|
||||
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
|
||||
let found: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().device_type)).collect();
|
||||
log::info!("graphics: {backends:?} adapters: {found:?}");
|
||||
adapters.iter().any(|a| a.get_info().device_type != eframe::wgpu::DeviceType::Cpu)
|
||||
}
|
||||
|
||||
/// The message shown when no window could be opened at all.
|
||||
pub fn no_window_message(error: &str, settings: Option<&Path>) -> String {
|
||||
let folder = settings.map(|d| d.display().to_string()).unwrap_or_else(|| "the SolidCraft settings folder".into());
|
||||
@@ -182,6 +244,65 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_starts_d3d12_first_and_vulkan_only_when_d3d12_has_no_gpu() {
|
||||
use eframe::wgpu::Backends;
|
||||
let probe = |with: &'static [Backends]| {
|
||||
move |b: Backends| {
|
||||
assert!(b == Backends::DX12 || b == Backends::VULKAN || b == Backends::GL, "one backend at a time: {b:?}");
|
||||
with.contains(&b)
|
||||
}
|
||||
};
|
||||
// D3D12 works: Vulkan and OpenGL are never even asked about (starting Vulkan loads
|
||||
// its drivers, which is what crashed).
|
||||
let mut asked = Vec::new();
|
||||
let chosen = choose_backends(true, false, false, None, |b| {
|
||||
asked.push(b);
|
||||
b == Backends::DX12
|
||||
});
|
||||
assert_eq!(chosen, Some(Backends::DX12));
|
||||
assert_eq!(asked, [Backends::DX12]);
|
||||
// Everything works: still D3D12 alone.
|
||||
assert_eq!(choose_backends(true, false, false, None, probe(&[Backends::DX12, Backends::VULKAN, Backends::GL])), Some(Backends::DX12));
|
||||
// No D3D12 hardware adapter: Vulkan, else OpenGL, else D3D12's software adapter (WARP).
|
||||
assert_eq!(choose_backends(true, false, false, None, probe(&[Backends::VULKAN, Backends::GL])), Some(Backends::VULKAN));
|
||||
assert_eq!(choose_backends(true, false, false, None, probe(&[Backends::GL])), Some(Backends::GL));
|
||||
assert_eq!(choose_backends(true, false, false, None, probe(&[])), Some(Backends::DX12));
|
||||
// `auto` and blanks are the default order.
|
||||
assert_eq!(choose_backends(true, false, false, Some(" auto "), probe(&[Backends::DX12])), Some(Backends::DX12));
|
||||
assert_eq!(choose_backends(true, false, false, Some(""), probe(&[Backends::GL])), Some(Backends::GL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgpu_backend_the_preference_and_the_software_renderer_override_the_order() {
|
||||
use eframe::wgpu::Backends;
|
||||
let never = |b: Backends| -> bool { panic!("no probing expected, asked about {b:?}") };
|
||||
// WGPU_BACKEND: wgpu reads it itself, nothing is probed.
|
||||
assert_eq!(choose_backends(true, true, false, Some("gl"), never), None);
|
||||
assert_eq!(choose_backends(true, true, true, None, never), None);
|
||||
// The software renderer (safe mode, the retry): WARP through D3D12 alone, never Vulkan.
|
||||
assert_eq!(choose_backends(true, false, true, Some("vulkan"), never), Some(Backends::DX12));
|
||||
assert_eq!(choose_backends(false, false, true, None, never), None);
|
||||
// The preference, on any system, without probing.
|
||||
assert_eq!(choose_backends(true, false, false, Some("vulkan"), never), Some(Backends::VULKAN));
|
||||
assert_eq!(choose_backends(true, false, false, Some("GL"), never), Some(Backends::GL));
|
||||
assert_eq!(choose_backends(true, false, false, Some("dx12,vulkan"), never), Some(Backends::DX12 | Backends::VULKAN));
|
||||
assert_eq!(choose_backends(false, false, false, Some("vulkan"), never), Some(Backends::VULKAN));
|
||||
// An unknown preference is the default order.
|
||||
assert_eq!(choose_backends(true, false, false, Some("glide"), |b| b == Backends::DX12), Some(Backends::DX12));
|
||||
// Elsewhere: wgpu's own choice.
|
||||
assert_eq!(choose_backends(false, false, false, None, never), None);
|
||||
assert_eq!(choose_backends(false, false, false, Some("auto"), never), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probing_a_backend_alone_never_panics() {
|
||||
// Whatever this machine has (often nothing): an answer, not a crash.
|
||||
for b in WINDOWS_ORDER {
|
||||
let _ = has_hardware_adapter(b);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_message_names_the_settings_folder_and_safe_mode() {
|
||||
let m = no_window_message("no adapter", Some(Path::new("/x/SolidCraft")));
|
||||
|
||||
@@ -100,7 +100,7 @@ pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
|
||||
let face = |col: Color32, pts: &[(f32, f32)]| {
|
||||
p.add(egui::Shape::convex_polygon(m(pts), col, Stroke::NONE));
|
||||
};
|
||||
let (light, mid, dark) = (Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197));
|
||||
let [light, mid, dark, edge_col] = crate::theme::Tokens::get().logo;
|
||||
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
|
||||
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
|
||||
face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
|
||||
@@ -111,7 +111,7 @@ pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
|
||||
face(dark, &[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)]);
|
||||
face(dark, &[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)]);
|
||||
// White edge lines.
|
||||
let edge = Stroke::new(10.0 * s, Color32::WHITE);
|
||||
let edge = Stroke::new(10.0 * s, edge_col);
|
||||
let line = |pts: &[(f32, f32)]| {
|
||||
p.add(egui::Shape::line(m(pts), edge));
|
||||
};
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
|
||||
use egui::{Color32, Mesh, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
|
||||
|
||||
/// Surface sheets: a sheet, a second sheet beside it, a lighter sheet (an opening, a blend or
|
||||
/// round strip), and the iso lines and comb teeth drawn on sheets.
|
||||
const SHEET: Color32 = Color32::from_rgb(170, 200, 240);
|
||||
const SHEET_2: Color32 = Color32::from_rgb(150, 185, 235);
|
||||
const SHEET_LIGHT: Color32 = Color32::from_rgb(214, 229, 248);
|
||||
const SHEET_LINE: Color32 = Color32::from_rgb(127, 166, 218);
|
||||
|
||||
/// `n + 1` points along the cubic Bézier curve with control points `c`.
|
||||
fn bezier(c: [(f32, f32); 4], n: usize) -> Vec<(f32, f32)> {
|
||||
(0..=n)
|
||||
@@ -127,6 +120,12 @@ fn line_type_dot_radius(r: Rect) -> f32 {
|
||||
|
||||
/// Paint the icon `name` into `r`. `accent` highlights the "action" part.
|
||||
pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32) {
|
||||
paint_with(p, r, name, ink, fill, accent, &crate::theme::Tokens::get().icons);
|
||||
}
|
||||
|
||||
/// [`paint`] with the colours an icon draws of its own given (`theme::IconColors`).
|
||||
pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32, ic: &crate::theme::IconColors) {
|
||||
let ic = *ic;
|
||||
let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) };
|
||||
let a = pen.accent;
|
||||
match name {
|
||||
@@ -164,7 +163,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
"sphere" => {
|
||||
pen.circle(12.0, 12.0, 8.5, pen.fill, ink);
|
||||
pen.arc(12.0, 12.0, 8.5, 3.3, 6.1, ink.gamma_multiply(0.5));
|
||||
p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), Color32::from_white_alpha(160));
|
||||
p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), ic.glint);
|
||||
}
|
||||
"torus" => {
|
||||
let pts: Vec<Pos2> =
|
||||
@@ -172,7 +171,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
p.add(Shape::convex_polygon(pts, pen.fill, Stroke::new(pen.w, ink)));
|
||||
let pts: Vec<Pos2> =
|
||||
(0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 3.5 * t.cos(), 11.0 + 1.8 * t.sin())).collect();
|
||||
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
|
||||
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink)));
|
||||
}
|
||||
"fillet" => {
|
||||
pen.poly(&[(4.0, 20.0), (4.0, 4.0), (10.0, 4.0), (10.0, 14.0), (20.0, 14.0), (20.0, 20.0)], pen.fill, Color32::TRANSPARENT);
|
||||
@@ -214,7 +213,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.poly(&[(3.0, 8.0), (21.0, 8.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink);
|
||||
let pts: Vec<Pos2> =
|
||||
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 5.0 * t.cos(), 8.0 + 2.0 * t.sin())).collect();
|
||||
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, a)));
|
||||
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, a)));
|
||||
pen.line(&[(7.0, 8.0), (7.0, 16.0)], a);
|
||||
pen.line(&[(17.0, 8.0), (17.0, 16.0)], a);
|
||||
}
|
||||
@@ -231,7 +230,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
"shell" => {
|
||||
pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0);
|
||||
pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], Color32::WHITE, a);
|
||||
pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], ic.highlight, a);
|
||||
}
|
||||
"draft" => {
|
||||
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], pen.fill, ink);
|
||||
@@ -478,7 +477,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
"c_coincident" => {
|
||||
pen.line(&[(3.0, 18.0), (12.0, 12.0)], ink);
|
||||
pen.line(&[(12.0, 12.0), (21.0, 16.0)], ink);
|
||||
pen.circle(12.0, 12.0, 2.6, Color32::WHITE, a);
|
||||
pen.circle(12.0, 12.0, 2.6, ic.highlight, a);
|
||||
}
|
||||
"c_tangent" => {
|
||||
pen.circle(12.0, 14.0, 6.0, Color32::TRANSPARENT, ink);
|
||||
@@ -525,7 +524,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.arrow((x1 + (x0 - x1) * 0.15, 9.0), (x1, 9.0), ink);
|
||||
}
|
||||
"new" => {
|
||||
pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], Color32::WHITE, ink);
|
||||
pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], ic.highlight, ink);
|
||||
pen.line(&[(15.0, 3.0), (15.0, 7.0), (19.0, 7.0)], ink);
|
||||
}
|
||||
"open" => {
|
||||
@@ -533,7 +532,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
"save" => {
|
||||
pen.poly(&[(4.0, 4.0), (17.0, 4.0), (20.0, 7.0), (20.0, 20.0), (4.0, 20.0)], pen.fill, ink);
|
||||
pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], Color32::WHITE, ink);
|
||||
pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], ic.highlight, ink);
|
||||
}
|
||||
"export" => {
|
||||
pen.poly(&[(4.0, 10.0), (4.0, 20.0), (16.0, 20.0), (16.0, 10.0)], pen.fill, ink);
|
||||
@@ -572,7 +571,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
}
|
||||
"zoom" => {
|
||||
pen.circle(10.0, 10.0, 6.0, Color32::WHITE, ink);
|
||||
pen.circle(10.0, 10.0, 6.0, ic.highlight, ink);
|
||||
pen.line(&[(14.5, 14.5), (20.0, 20.0)], ink);
|
||||
}
|
||||
"perspective" => {
|
||||
@@ -590,13 +589,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.iso_box(2.0, 13.0, 9.0, 6.0, 6.0);
|
||||
pen.iso_box(9.0, 6.0, 5.0, 4.0, 4.0);
|
||||
}
|
||||
"folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], Color32::from_rgb(236, 200, 110), ink),
|
||||
"folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], ic.folder, ink),
|
||||
"origin" => {
|
||||
pen.arrow((6.0, 18.0), (20.0, 18.0), Color32::from_rgb(210, 60, 60));
|
||||
pen.arrow((6.0, 18.0), (6.0, 4.0), Color32::from_rgb(60, 100, 220));
|
||||
pen.arrow((6.0, 18.0), (15.0, 9.0), Color32::from_rgb(60, 160, 60));
|
||||
pen.arrow((6.0, 18.0), (20.0, 18.0), ic.axes[0]);
|
||||
pen.arrow((6.0, 18.0), (6.0, 4.0), ic.axes[2]);
|
||||
pen.arrow((6.0, 18.0), (15.0, 9.0), ic.axes[1]);
|
||||
}
|
||||
"plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], Color32::from_rgb(244, 214, 150), ink),
|
||||
"plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], ic.plane, ink),
|
||||
"axis" => {
|
||||
pen.line(&[(4.0, 20.0), (20.0, 4.0)], ink);
|
||||
pen.circle(4.0, 20.0, 2.0, ink, ink);
|
||||
@@ -825,8 +824,8 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
}
|
||||
"accessibility" => {
|
||||
pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], Color32::from_rgb(70, 180, 90), ink);
|
||||
pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], Color32::from_rgb(220, 70, 60), Color32::TRANSPARENT);
|
||||
pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], ic.access[0], ink);
|
||||
pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], ic.access[1], Color32::TRANSPARENT);
|
||||
pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 12.5), (6.0, 12.5)], pen.fill, ink);
|
||||
pen.arrow((4.0, 2.0), (4.0, 13.0), a);
|
||||
pen.arrow((20.0, 2.0), (20.0, 13.0), a);
|
||||
@@ -834,10 +833,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
"environment_map" => {
|
||||
let pts: Vec<Pos2> =
|
||||
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
|
||||
p.add(Shape::convex_polygon(pts, Color32::from_rgb(190, 205, 230), Stroke::new(pen.w, ink)));
|
||||
pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], Color32::from_rgb(60, 62, 70), Color32::TRANSPARENT);
|
||||
pen.line(&[(3.2, 12.6), (20.8, 12.6)], Color32::WHITE);
|
||||
p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), Color32::WHITE);
|
||||
p.add(Shape::convex_polygon(pts, ic.sky, Stroke::new(pen.w, ink)));
|
||||
pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], ic.floor, Color32::TRANSPARENT);
|
||||
pen.line(&[(3.2, 12.6), (20.8, 12.6)], ic.highlight);
|
||||
p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), ic.highlight);
|
||||
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
|
||||
}
|
||||
"zebra" | "draft_analysis" | "curvature_map" => {
|
||||
@@ -845,27 +844,23 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
|
||||
match name {
|
||||
"zebra" => {
|
||||
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
|
||||
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink)));
|
||||
for k in 0..3 {
|
||||
let y = 6.5 + k as f32 * 4.5;
|
||||
let h = (81.0 - (y + 1.0 - 12.0) * (y + 1.0 - 12.0)).max(0.0).sqrt();
|
||||
pen.poly(
|
||||
&[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)],
|
||||
Color32::from_gray(30),
|
||||
Color32::TRANSPARENT,
|
||||
);
|
||||
pen.poly(&[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)], ic.stripe, Color32::TRANSPARENT);
|
||||
}
|
||||
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
|
||||
}
|
||||
"draft_analysis" => {
|
||||
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], Color32::from_rgb(70, 180, 90), ink);
|
||||
pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], Color32::from_rgb(235, 200, 50), Color32::TRANSPARENT);
|
||||
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], ic.draft[0], ink);
|
||||
pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], ic.draft[1], Color32::TRANSPARENT);
|
||||
pen.arrow((12.0, 16.0), (12.0, 1.5), a);
|
||||
}
|
||||
_ => {
|
||||
p.add(Shape::convex_polygon(pts, Color32::from_rgb(60, 110, 230), Stroke::new(pen.w, ink)));
|
||||
pen.circle(14.0, 10.0, 5.0, Color32::from_rgb(70, 190, 90), Color32::TRANSPARENT);
|
||||
pen.circle(15.5, 8.5, 2.2, Color32::from_rgb(225, 60, 45), Color32::TRANSPARENT);
|
||||
p.add(Shape::convex_polygon(pts, ic.curvature[0], Stroke::new(pen.w, ink)));
|
||||
pen.circle(14.0, 10.0, 5.0, ic.curvature[1], Color32::TRANSPARENT);
|
||||
pen.circle(15.5, 8.5, 2.2, ic.curvature[2], Color32::TRANSPARENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -983,13 +978,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
"patch" => {
|
||||
// A wire loop with a surface spanned across it.
|
||||
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], SHEET, ink);
|
||||
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], ic.sheet, ink);
|
||||
pen.line(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0), (4.0, 16.0)], a);
|
||||
}
|
||||
"stitch" => {
|
||||
// Two surface patches sewn along their shared edge.
|
||||
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], SHEET, ink);
|
||||
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], SHEET_2, ink);
|
||||
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], ic.sheet, ink);
|
||||
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], ic.sheet_2, ink);
|
||||
for y in [6.0, 9.0, 12.0] {
|
||||
pen.line(&[(10.0, y), (14.0, y + 1.5)], a);
|
||||
}
|
||||
@@ -999,7 +994,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
let f = [(2.0, 12.5), (7.0, 9.5), (12.0, 15.5), (17.0, 12.5)];
|
||||
let b = [(6.4, 8.9), (11.4, 5.9), (16.4, 11.9), (21.4, 8.9)];
|
||||
let d = f.map(|(x, y)| (x, y + 6.0));
|
||||
pen.band(&bezier(b, 16), &bezier(f, 16), SHEET);
|
||||
pen.band(&bezier(b, 16), &bezier(f, 16), ic.sheet);
|
||||
pen.band(&bezier(f, 16), &bezier(d, 16), pen.fill.gamma_multiply(0.85));
|
||||
pen.poly(&[(17.0, 12.5), (21.4, 8.9), (21.4, 14.9), (17.0, 18.5)], pen.fill.gamma_multiply(0.7), ink);
|
||||
pen.line(&[(2.0, 12.5), (2.0, 18.5)], ink);
|
||||
@@ -1042,7 +1037,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// An open profile (accent) pulled up into a sheet.
|
||||
let top = [(4.0, 11.0), (8.0, 8.0), (13.0, 14.0), (20.0, 10.0)];
|
||||
let bottom = [(4.0, 20.0), (8.0, 17.0), (13.0, 23.0), (20.0, 19.0)];
|
||||
pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
|
||||
pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet);
|
||||
pen.line(&[(4.0, 11.0), (4.0, 20.0)], ink);
|
||||
pen.line(&[(20.0, 10.0), (20.0, 19.0)], ink);
|
||||
pen.curve(top, ink);
|
||||
@@ -1053,13 +1048,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// An open profile (accent) turned about the axis into a hollow band.
|
||||
let left = [(5.0, 6.0), (7.0, 10.0), (5.0, 14.0), (6.0, 18.0)];
|
||||
let right = [(19.0, 6.0), (17.0, 10.0), (19.0, 14.0), (18.0, 18.0)];
|
||||
pen.band(&bezier(left, 16), &bezier(right, 16), SHEET);
|
||||
pen.band(&bezier(left, 16), &bezier(right, 16), ic.sheet);
|
||||
let front = ellipse(12.0, 18.0, 6.0, 2.2, 0.0, std::f32::consts::PI, 16);
|
||||
pen.poly(&front, SHEET, Color32::TRANSPARENT);
|
||||
pen.poly(&front, ic.sheet, Color32::TRANSPARENT);
|
||||
// A closed fill takes each point once: a repeated end point spikes `poly`'s edge.
|
||||
let mut opening = ellipse(12.0, 6.0, 7.0, 2.2, 0.0, std::f32::consts::TAU, 32);
|
||||
opening.pop();
|
||||
pen.poly(&opening, SHEET_LIGHT, ink);
|
||||
pen.poly(&opening, ic.sheet_light, ink);
|
||||
pen.line(&front, ink);
|
||||
pen.curve(left, ink);
|
||||
pen.line(&[(12.0, 1.5), (12.0, 3.8)], ink);
|
||||
@@ -1070,7 +1065,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// A straight profile (accent) carried along a curved path: a ribbon.
|
||||
let near = [(4.0, 13.0), (8.0, 5.0), (14.0, 5.0), (20.0, 9.0)];
|
||||
let far = [(4.0, 20.0), (8.0, 12.0), (14.0, 12.0), (20.0, 16.0)];
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET);
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet);
|
||||
pen.curve(near, ink);
|
||||
pen.curve(far, ink);
|
||||
pen.line(&[(4.0, 13.0), (4.0, 20.0)], a);
|
||||
@@ -1080,7 +1075,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// Two open sections (accent) joined by a sheet.
|
||||
let top = [(5.0, 6.0), (9.0, 3.0), (14.0, 8.0), (19.0, 5.0)];
|
||||
let bottom = [(3.0, 19.0), (8.0, 15.0), (14.0, 22.0), (21.0, 18.0)];
|
||||
pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
|
||||
pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet);
|
||||
pen.line(&[(5.0, 6.0), (3.0, 19.0)], ink);
|
||||
pen.line(&[(19.0, 5.0), (21.0, 18.0)], ink);
|
||||
pen.curve(top, a);
|
||||
@@ -1090,7 +1085,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// A wavy sheet and its offset copy (dashed), the distance shown by the arrow.
|
||||
let back = [(6.0, 14.0), (10.0, 11.0), (15.0, 17.0), (21.0, 14.0)];
|
||||
let front = [(3.0, 20.0), (7.0, 17.0), (12.0, 23.0), (18.0, 20.0)];
|
||||
pen.band(&bezier(back, 16), &bezier(front, 16), SHEET);
|
||||
pen.band(&bezier(back, 16), &bezier(front, 16), ic.sheet);
|
||||
pen.curve(back, ink);
|
||||
pen.curve(front, ink);
|
||||
pen.line(&[(6.0, 14.0), (3.0, 20.0)], ink);
|
||||
@@ -1104,7 +1099,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
"ruled" => {
|
||||
// A strip hanging off a block's edge (accent), ruled by straight lines.
|
||||
pen.poly(&[(2.0, 12.0), (12.0, 12.0), (12.0, 22.0), (2.0, 22.0)], pen.fill, ink);
|
||||
pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], SHEET, ink);
|
||||
pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], ic.sheet, ink);
|
||||
pen.line(&[(15.0, 9.3), (15.0, 19.3)], a);
|
||||
pen.line(&[(18.0, 6.7), (18.0, 16.7)], a);
|
||||
pen.line(&[(12.0, 12.0), (12.0, 22.0)], a);
|
||||
@@ -1140,13 +1135,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
})
|
||||
.collect();
|
||||
for (lo, hi) in rows.iter().zip(rows.iter().skip(1)) {
|
||||
pen.band(lo, hi, SHEET);
|
||||
pen.band(lo, hi, ic.sheet);
|
||||
}
|
||||
for j in [n / 3, 2 * n / 3] {
|
||||
if let Some(r) = rows.get(j) {
|
||||
pen.line(r, SHEET_LINE);
|
||||
pen.line(r, ic.sheet_line);
|
||||
}
|
||||
pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::<Vec<_>>(), SHEET_LINE);
|
||||
pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::<Vec<_>>(), ic.sheet_line);
|
||||
}
|
||||
for c in [left, top, right, bottom] {
|
||||
pen.curve(c, a);
|
||||
@@ -1160,29 +1155,29 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// leaves both edges tangent.
|
||||
let near = [(13.0, 20.0), (8.5, 20.0), (10.5, 11.0), (6.0, 11.0)];
|
||||
let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
|
||||
pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], SHEET, ink);
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
|
||||
pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], ic.sheet, ink);
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light);
|
||||
pen.curve(far, a);
|
||||
pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], SHEET, ink);
|
||||
pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], ic.sheet, ink);
|
||||
pen.curve(near, a);
|
||||
}
|
||||
"untrim" => {
|
||||
// A trimmed sheet whose hole (dashed, accent) is filled back in.
|
||||
pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], SHEET, ink);
|
||||
pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], ic.sheet, ink);
|
||||
let hole = ellipse(12.2, 12.3, 4.2, 3.2, 0.0, std::f32::consts::TAU, 32);
|
||||
pen.poly(hole.get(1..).unwrap_or_default(), SHEET_LIGHT, Color32::TRANSPARENT);
|
||||
pen.poly(hole.get(1..).unwrap_or_default(), ic.sheet_light, Color32::TRANSPARENT);
|
||||
pen.dashed(&hole, a);
|
||||
}
|
||||
"unstitch" => {
|
||||
// Two sheets pulled apart from their shared edge.
|
||||
pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], SHEET, ink);
|
||||
pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], SHEET_2, ink);
|
||||
pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], ic.sheet, ink);
|
||||
pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], ic.sheet_2, ink);
|
||||
pen.arrow((10.8, 11.0), (7.0, 11.0), a);
|
||||
pen.arrow((13.2, 11.0), (17.0, 11.0), a);
|
||||
}
|
||||
"reverse_normal" => {
|
||||
// A sheet with its old normal (faded) and the flipped one (accent).
|
||||
pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], SHEET, ink);
|
||||
pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], ic.sheet, ink);
|
||||
pen.arrow((12.0, 14.0), (12.0, 3.5), ink.gamma_multiply(0.5));
|
||||
pen.arrow((12.0, 14.0), (12.0, 22.5), a);
|
||||
}
|
||||
@@ -1190,10 +1185,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// A wall sheet and a floor sheet joined by a round strip (accent edges).
|
||||
let near = [(3.0, 16.0), (3.0, 18.8), (5.2, 21.0), (8.0, 21.0)];
|
||||
let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
|
||||
pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], SHEET_2, ink);
|
||||
pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], SHEET, ink);
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
|
||||
pen.line(&[(4.5, 19.5), (8.9, 15.9)], SHEET_LINE);
|
||||
pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], ic.sheet_2, ink);
|
||||
pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], ic.sheet, ink);
|
||||
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light);
|
||||
pen.line(&[(4.5, 19.5), (8.9, 15.9)], ic.sheet_line);
|
||||
pen.curve(far, a);
|
||||
pen.curve(near, a);
|
||||
}
|
||||
@@ -1209,11 +1204,11 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
let h = 0.8 + 4.2 * i as f32 / 10.0;
|
||||
let tip = (x + (y1 - y) / len * h, y - (x1 - x) / len * h);
|
||||
if i % 2 == 0 {
|
||||
pen.line(&[(*x, *y), tip], SHEET_LINE);
|
||||
pen.line(&[(*x, *y), tip], ic.sheet_line);
|
||||
}
|
||||
tips.push(tip);
|
||||
}
|
||||
pen.line(&tips, SHEET_LINE);
|
||||
pen.line(&tips, ic.sheet_line);
|
||||
pen.curve(c, a);
|
||||
pen.dot(12.0, 13.0, a);
|
||||
}
|
||||
@@ -1256,7 +1251,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
"interference" => {
|
||||
pen.closed(&[(3.0, 5.0), (14.0, 5.0), (14.0, 16.0), (3.0, 16.0)], ink);
|
||||
pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], Color32::from_rgb(225, 70, 60), Color32::from_rgb(225, 70, 60));
|
||||
pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], ic.interference, ic.interference);
|
||||
pen.closed(&[(9.0, 10.0), (21.0, 10.0), (21.0, 20.0), (9.0, 20.0)], ink);
|
||||
}
|
||||
// Sheet metal: thin plates (fill), the made or changed part in the accent.
|
||||
@@ -1283,7 +1278,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
"fold" => {
|
||||
pen.line(&[(2.0, 18.0), (12.0, 18.0)], ink);
|
||||
pen.line(&[(12.0, 18.0), (19.0, 6.0)], a);
|
||||
pen.line(&[(9.0, 21.0), (15.0, 15.0)], Color32::from_rgb(130, 140, 160));
|
||||
pen.line(&[(9.0, 21.0), (15.0, 15.0)], ic.fold);
|
||||
pen.arc(12.0, 18.0, 6.0, 0.2, 1.0, a);
|
||||
pen.arrow((18.0, 16.0), (17.0, 12.5), a);
|
||||
}
|
||||
@@ -1358,16 +1353,23 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
// Appearance: a shaded ball with three paint dabs.
|
||||
"appearance" => {
|
||||
pen.circle(10.0, 13.0, 7.5, pen.fill, ink);
|
||||
pen.arc(10.0, 13.0, 4.5, 1.8, 3.2, Color32::WHITE);
|
||||
pen.circle(18.0, 5.0, 2.6, Color32::from_rgb(220, 70, 60), ink);
|
||||
pen.circle(20.5, 11.0, 2.6, Color32::from_rgb(70, 150, 230), ink);
|
||||
pen.circle(19.0, 17.5, 2.6, Color32::from_rgb(240, 200, 60), ink);
|
||||
pen.arc(10.0, 13.0, 4.5, 1.8, 3.2, ic.highlight);
|
||||
pen.circle(18.0, 5.0, 2.6, ic.paint[0], ink);
|
||||
pen.circle(20.5, 11.0, 2.6, ic.paint[1], ink);
|
||||
pen.circle(19.0, 17.5, 2.6, ic.paint[2], ink);
|
||||
}
|
||||
"warning" => {
|
||||
pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], Color32::from_rgb(250, 200, 60), ink);
|
||||
pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], ic.warning, ink);
|
||||
pen.line(&[(12.0, 9.0), (12.0, 14.0)], ink);
|
||||
pen.dot(12.0, 17.0, ink);
|
||||
}
|
||||
// A failed feature: a disc with a cross, a different shape from the warning's triangle, so
|
||||
// it reads without colour (#972). Drawn in the caller's colours only.
|
||||
"error" => {
|
||||
pen.circle(12.0, 12.0, 10.0, pen.fill, ink);
|
||||
pen.line(&[(8.0, 8.0), (16.0, 16.0)], ink);
|
||||
pen.line(&[(16.0, 8.0), (8.0, 16.0)], ink);
|
||||
}
|
||||
_ => {
|
||||
pen.poly(&[(5.0, 5.0), (19.0, 5.0), (19.0, 19.0), (5.0, 19.0)], pen.fill, ink);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ use egui::{Color32, Pos2, Stroke, vec2};
|
||||
|
||||
pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: SnapKind, col: Color32, tag: Option<&str>) {
|
||||
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
|
||||
let halo = Stroke::new(4.5, Color32::from_black_alpha(150));
|
||||
let white = Stroke::new(1.5, Color32::WHITE);
|
||||
let ic = Tokens::get().icons;
|
||||
let halo = Stroke::new(4.5, ic.marker_halo);
|
||||
let white = Stroke::new(1.5, ic.highlight);
|
||||
if let Some(tip) = tip {
|
||||
let d = (tip - c).normalized();
|
||||
let n = vec2(-d.y, d.x);
|
||||
@@ -30,7 +31,7 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
|
||||
}
|
||||
}
|
||||
};
|
||||
painter.add(shape(Color32::from_black_alpha(150), Stroke::NONE, 4.0));
|
||||
painter.add(shape(ic.marker_halo, Stroke::NONE, 4.0));
|
||||
painter.add(shape(col, Stroke::NONE, 0.0));
|
||||
match kind {
|
||||
SnapKind::FaceCenter => {
|
||||
@@ -38,8 +39,8 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
|
||||
}
|
||||
SnapKind::CircleCenter => {
|
||||
painter.circle_stroke(c, 7.0, white);
|
||||
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, Color32::WHITE));
|
||||
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE));
|
||||
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, ic.highlight));
|
||||
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, ic.highlight));
|
||||
}
|
||||
SnapKind::EdgeMid | SnapKind::Vertex => {
|
||||
painter.add(shape(Color32::TRANSPARENT, white, 0.0));
|
||||
|
||||
@@ -151,13 +151,12 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h
|
||||
pub(crate) const AXIS_RADIUS: f32 = 11.0;
|
||||
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
|
||||
let col = if d.x != 0.0 {
|
||||
solidcraft_engine::view::colors::AXIS_X
|
||||
t.triad[0]
|
||||
} else if d.y != 0.0 {
|
||||
solidcraft_engine::view::colors::AXIS_Y
|
||||
t.triad[1]
|
||||
} else {
|
||||
solidcraft_engine::view::colors::AXIS_Z
|
||||
t.triad[2]
|
||||
};
|
||||
let col = Color32::from_rgb(col.0, col.1, col.2);
|
||||
let positive = !name.starts_with('-');
|
||||
let ink = if depth < -0.01 { col.gamma_multiply(0.55) } else { col };
|
||||
painter.line_segment([c, at], Stroke::new(if positive { 2.0 } else { 1.0 }, ink));
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
//! Original agent pointer and label. Animation stays with the UI controller.
|
||||
use egui::{Color32, FontId, Pos2, Shape, Stroke, vec2};
|
||||
pub(crate) const AGENT: Color32 = Color32::from_rgb(168, 85, 247);
|
||||
/// The agent pointer's colour (a theme token, so a colour vision mode can reach it).
|
||||
pub(crate) fn agent() -> Color32 {
|
||||
crate::theme::Tokens::get().icons.agent
|
||||
}
|
||||
pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
|
||||
let ic = crate::theme::Tokens::get().icons;
|
||||
// An arrow pointer, tip at `p`.
|
||||
let pts: Vec<Pos2> =
|
||||
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
|
||||
painter.add(Shape::convex_polygon(pts.clone(), AGENT, Stroke::NONE));
|
||||
painter.add(Shape::closed_line(pts, Stroke::new(1.5, Color32::WHITE)));
|
||||
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), Color32::WHITE);
|
||||
painter.add(Shape::convex_polygon(pts.clone(), ic.agent, Stroke::NONE));
|
||||
painter.add(Shape::closed_line(pts, Stroke::new(1.5, ic.highlight)));
|
||||
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), ic.highlight);
|
||||
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
|
||||
painter.rect_filled(r, 4.0, AGENT);
|
||||
painter.galley(r.min + vec2(5.0, 2.0), label, Color32::WHITE);
|
||||
painter.rect_filled(r, 4.0, ic.agent);
|
||||
painter.galley(r.min + vec2(5.0, 2.0), label, ic.highlight);
|
||||
}
|
||||
+145
-1
@@ -4,12 +4,19 @@
|
||||
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Unten den GitHub-Entwurf öffnen. Titel und Bericht werden vorausgefüllt.",
|
||||
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Screenshot anhängen: kopieren und einfügen oder als PNG speichern und hochladen.",
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Auf GitHub auf Submit new issue klicken, um den Bericht zu senden.",
|
||||
"nav.touchpad.label": "Touchpad",
|
||||
"nav.touchpad.help": "Umschalt gedrückt halten und den Zeiger zum Verschieben bewegen; mit Alt/Option drehen. Keine Maustaste nötig. Vertikales Scrollen zoomt.",
|
||||
"nav.touchpad.orbit": "Drehen (Alt/Option halten und bewegen; ohne Maustaste)",
|
||||
"nav.touchpad.pan": "Verschieben (Umschalt halten und bewegen; ohne Maustaste)",
|
||||
"nav.touchpad.zoom": "Zoom (vertikal scrollen oder Finger spreizen/zusammenziehen)",
|
||||
"nav.touchpad.scroll": "Touchpad verwendet vertikales Scrollen zum Zoomen. Die Zwei-Finger-Verschiebung bleibt für andere Vorgaben gespeichert.",
|
||||
"browser.body_name": "Körper {number}",
|
||||
"browser.component_name": "Komponente {number}",
|
||||
"browser.parameters": "Parameter ({count})",
|
||||
"browser.precision": "Genauigkeit: {precision}",
|
||||
"browser.sketch_name": "Skizze {number}",
|
||||
"browser.units": "Einheiten: {units}",
|
||||
"dialog.computing": "Wird berechnet… {seconds} s",
|
||||
"dialog.custom_axis": "↖ Eigene Achse",
|
||||
"dialog.edit_title": "{title} bearbeiten",
|
||||
"dialog.select": "↖ Auswählen",
|
||||
@@ -42,6 +49,16 @@
|
||||
"ui.animation": "Animation",
|
||||
"ui.animation_speed": "Animationsgeschwindigkeit",
|
||||
"ui.anti_aliasing": "Kantenglättung",
|
||||
"ui.supersampling": "Supersampling (SSAA)",
|
||||
"ui.supersampling_off": "Aus (1×)",
|
||||
"ui.supersampling_adaptive": "Nur bei ruhender Ansicht",
|
||||
"settings.supersampling_note": "Zeichnet die Ansicht in höherer Auflösung und rechnet sie herunter: glattere Kanten, Linien und Raster auf Bildschirmen unter 4K. Kostet GPU-Zeit: etwa 2,25-mal so viele Pixel bei 1,5× und 4-mal bei 2×.",
|
||||
"ui.display_quality": "Darstellungsqualität",
|
||||
"ui.display_quality_coarse": "Grob",
|
||||
"ui.display_quality_normal": "Normal",
|
||||
"ui.display_quality_fine": "Fein",
|
||||
"ui.display_quality_ultra": "Ultra",
|
||||
"settings.display_quality_note": "Feinere Stufen zeigen gekrümmte Flächen und Kanten glatter und brauchen länger zum Vernetzen. Nur die Ansicht ändert sich, nie das Design oder Exporte.",
|
||||
"ui.apply": "Anwenden",
|
||||
"ui.arc": "Kreisbogen",
|
||||
"ui.assemble": "Zusammenbau",
|
||||
@@ -72,6 +89,21 @@
|
||||
"ui.coincident": "Koinzident",
|
||||
"ui.collapse": "Einklappen",
|
||||
"ui.collinear": "Kollinear",
|
||||
"ui.colour_vision": "Farbsehen",
|
||||
"ui.colour_vision_deutan": "Deutan",
|
||||
"ui.colour_vision_deutan_tip": "Bei schwachem oder fehlendem Grünsinn",
|
||||
"ui.colour_vision_failed": "Fehler",
|
||||
"ui.colour_vision_fixed": "Fixiert",
|
||||
"ui.colour_vision_mode": "Modus",
|
||||
"ui.colour_vision_note": "Ändert die Farben, die eine Bedeutung tragen: Skizzenstatus, Achsen, Biegungen, Analysen und Symbole. Wenn Sie die Farbfilter von Windows verwenden, lassen Sie dies auf „Aus“.",
|
||||
"ui.colour_vision_off": "Aus",
|
||||
"ui.colour_vision_off_tip": "Die eigenen Farben des Designs",
|
||||
"ui.colour_vision_projected": "Projiziert",
|
||||
"ui.colour_vision_protan": "Protan",
|
||||
"ui.colour_vision_protan_tip": "Bei schwachem oder fehlendem Rotsinn",
|
||||
"ui.colour_vision_sample": "Beispiel",
|
||||
"ui.colour_vision_tritan": "Tritan",
|
||||
"ui.colour_vision_tritan_tip": "Bei schwachem oder fehlendem Blausinn",
|
||||
"ui.combine": "Kombinieren",
|
||||
"ui.coming_later": "bald verfügbar",
|
||||
"ui.concentric": "Konzentrisch",
|
||||
@@ -265,6 +297,10 @@
|
||||
"ui.quantity": "Anzahl",
|
||||
"ui.quit": "Beenden",
|
||||
"ui.radius": "Radius",
|
||||
"ui.pull_direction": "Entformungsrichtung",
|
||||
"ui.tool_direction": "Werkzeugrichtung",
|
||||
"ui.reachable": "Erreichbar",
|
||||
"ui.blocked": "Verdeckt",
|
||||
"ui.radius_type": "Radius-Typ",
|
||||
"ui.rectangle": "Rechteck",
|
||||
"ui.rectangular_pattern": "Rechteckmuster",
|
||||
@@ -276,6 +312,24 @@
|
||||
"ui.revolve": "Drehen",
|
||||
"ui.right": "Rechts",
|
||||
"ui.save": "Speichern",
|
||||
"ui.heading_save_mesh": "ALS NETZ SPEICHERN",
|
||||
"ui.format": "Format",
|
||||
"ui.mesh_format_stl": "STL",
|
||||
"ui.mesh_format_3mf": "3MF",
|
||||
"ui.mesh_format_3mf_tagged": "3MF mit Flächenmarken",
|
||||
"ui.send_to_slicer": "An Slicer senden",
|
||||
"ui.slicer": "Slicer",
|
||||
"ui.custom_program": "Benutzerdefiniert…",
|
||||
"ui.no_slicer_found": "Kein Slicer gefunden",
|
||||
"ui.send": "Senden",
|
||||
"ui.custom_program_tip": "Ein Slicer-Programm zur Liste hinzufügen",
|
||||
"ui.slicer_delete_tip": "{program}\nMit Entf aus der Liste entfernen",
|
||||
"ui.save_mesh_pick_bodies": "Wähle die Körper zum Speichern aus",
|
||||
"ui.save_mesh_no_slicer": "Kein Slicer gefunden: „Benutzerdefiniert…“ wählen und das Slicer-Programm angeben, oder „An Slicer senden“ ausschalten",
|
||||
"ui.not_a_program": "{path} ist kein startbares Programm",
|
||||
"ui.sent_to_slicer": "An {slicer} gesendet",
|
||||
"ui.saved_to": "Gespeichert: {path}",
|
||||
"ui.save_mesh_temp_failed": "{folder} konnte nicht angelegt werden: {error}",
|
||||
"ui.save_as": "Speichern unter",
|
||||
"ui.save_as_ellipsis": "Speichern unter...",
|
||||
"ui.scale": "Skalieren",
|
||||
@@ -366,6 +420,17 @@
|
||||
"ui.pinToShortcuts": "An Verknüpfungen anheften",
|
||||
"ui.pinToToolbar": "An Symbolleiste anheften",
|
||||
"ui.properties": "Eigenschaften",
|
||||
"ui.design_properties.project": "Projekt",
|
||||
"ui.design_properties.part_number": "Teilenummer",
|
||||
"ui.design_properties.revision": "Revision",
|
||||
"ui.design_properties.author": "Autor",
|
||||
"ui.design_properties.organization": "Organisation",
|
||||
"ui.design_properties.description": "Beschreibung",
|
||||
"ui.design_properties.license": "Lizenz",
|
||||
"ui.design_properties.not_text": "Nicht als Text gespeichert; eine Eingabe hier ersetzt den Wert.",
|
||||
"ui.design_properties.exports": "STEP-, IGES- und 3MF-Exporte übernehmen diese Angaben, wo das Format ein Feld dafür hat.",
|
||||
"ui.design_properties.others": "Daten anderer Werkzeuge",
|
||||
"ui.design_properties.others_note": "Bleiben unverändert in der Konstruktion und werden nie exportiert.",
|
||||
"ui.removeSection": "Schnitt entfernen",
|
||||
"ui.removeFromToolbar": "Aus der Symbolleiste entfernen",
|
||||
"ui.repeat": "Wiederholen",
|
||||
@@ -389,6 +454,9 @@
|
||||
"ui.updateToCurrentView": "Auf aktuelle Ansicht aktualisieren",
|
||||
"ui.zoom": "Zoomen",
|
||||
"ui.command_done": "{command} fertig",
|
||||
"drive.stopped_by_loop": "Angehalten bei {value}: die Schleife über {joint} lässt sich nicht weiter schließen",
|
||||
"drive.stopped_by_contact": "Angehalten bei {value}: {a} würde {b} durchdringen",
|
||||
"drive.unconnected": "Bewegt nichts: {part} ist mit nichts Festgesetztem verbunden",
|
||||
"cmd.appearance_assign": "Erscheinungsbild",
|
||||
"cmd.appearance_library": "Erscheinungsbild-Bibliothek",
|
||||
"cmd.appearance_list": "Erscheinungsbilder auflisten",
|
||||
@@ -461,6 +529,9 @@
|
||||
"cmd.doc_recovery_list": "Wiederherstellbare Konstruktionen",
|
||||
"cmd.document_inspect": "Konstruktion prüfen",
|
||||
"cmd.document_units": "Einheiten der Konstruktion ändern",
|
||||
"cmd.document_metadata": "Konstruktionseigenschaften",
|
||||
"cmd.document_metadata_set": "Konstruktionseigenschaften ändern",
|
||||
"cmd.document_metadata_remove": "Konstruktionseigenschaft entfernen",
|
||||
"cmd.edit_checkpoint": "Prüfpunkt",
|
||||
"cmd.edit_restore_checkpoint": "Prüfpunkt wiederherstellen",
|
||||
"cmd.engine_commands": "Befehle auflisten",
|
||||
@@ -474,6 +545,7 @@
|
||||
"cmd.file_insert_mesh": "Netz einfügen",
|
||||
"cmd.file_insert_step": "STEP einfügen",
|
||||
"cmd.file_save_mesh": "Als Netz speichern",
|
||||
"cmd.file_slicers": "Installierte Slicer",
|
||||
"cmd.inspect_accessibility": "Zugänglichkeitsanalyse",
|
||||
"cmd.inspect_center_of_mass": "Massenschwerpunkt",
|
||||
"cmd.inspect_curvature_comb": "Krümmungskamm-Analyse",
|
||||
@@ -668,9 +740,12 @@
|
||||
"ui.heading_constraints": "RANDBEDINGUNGEN",
|
||||
"ui.heading_utilities": "WERKZEUGE",
|
||||
"BROWSER": "BROWSER",
|
||||
"Bends": "Biegungen",
|
||||
"Drive Joint": "Gelenk antreiben",
|
||||
"Edit Profile Sketch": "Profile-Skizze bearbeiten",
|
||||
"Find in Browser": "Im Browser suchen",
|
||||
"Copy Error Message": "Fehlermeldung kopieren",
|
||||
"Copy Warning Message": "Warnmeldung kopieren",
|
||||
"Report a bug — review a screenshot and open a GitHub draft": "Fehler melden — Screenshot prüfen und einen GitHub-Entwurf öffnen",
|
||||
"Reset Toolbar": "Symbolleiste zurücksetzen",
|
||||
"Select bodies (a face picks its body)": "Körper auswählen (eine Fläche wählt ihren Körper)",
|
||||
@@ -752,5 +827,74 @@
|
||||
"update.restart_later": "Später",
|
||||
"update.installer_started": "Das Installationsprogramm startet, wenn SolidCraft geschlossen wird.",
|
||||
"update.dmg_opened": "Das Disk-Image ist geöffnet und geprüft: SolidCraft in „Programme“ ziehen, die alte Version ersetzen und SolidCraft neu starten.",
|
||||
"update.install_failed": "Das Update wurde nicht installiert: {error}. Es wurde nichts verändert; „Herunterladen“ holt das Paket."
|
||||
"update.install_failed": "Das Update wurde nicht installiert: {error}. Es wurde nichts verändert; „Herunterladen“ holt das Paket.",
|
||||
"cmd.render_settings_get": "Rendereinstellungen",
|
||||
"cmd.render_settings_set": "Rendereinstellungen speichern",
|
||||
"cmd.render_scene": "Renderszene prüfen",
|
||||
"cmd.render_settings_apply": "Renderkamera anwenden",
|
||||
"toolbar.render": "RENDERN",
|
||||
"toolbar.render_scene": "SZENE",
|
||||
"render.preview": "Integrierte Vorschau",
|
||||
"render.preview_help": "Rasterdarstellung mit gemischter Transparenz. Brechung, HDRI und finales Rendering sind noch nicht verfügbar.",
|
||||
"render.setup_name": "Name des Setups",
|
||||
"render.background": "Szenenhintergrund",
|
||||
"render.perspective": "Perspektivische Kamera",
|
||||
"render.fit": "Produkt einpassen",
|
||||
"render.restore_camera": "Gespeicherte Kamera laden",
|
||||
"render.save_setup": "Setup speichern",
|
||||
"render.unsaved": "Ungespeicherte Vorschauänderungen",
|
||||
"render.reload_settings": "Gespeicherte Einstellungen laden",
|
||||
"render.unavailable_preview": "Dieses Setup verlangt ein nicht verfügbares Vorschauprofil.",
|
||||
"render.use_preview": "Integrierte Vorschau verwenden",
|
||||
"render.navigation_help": "Ziehen zum Drehen; mit mittlerer Taste ziehen zum Verschieben; scrollen zum Zoomen. Setup speichern übernimmt die aktuelle Kamera.",
|
||||
"render.return_design": "Zurück zu Design",
|
||||
"render.image_title": "Bild rendern",
|
||||
"render.image_help": "CPU-Vorschaubild · PNG · angenäherte Transparenz",
|
||||
"render.image_width": "Breite",
|
||||
"render.image_height": "Höhe",
|
||||
"render.image_transparent": "Transparenter Hintergrund",
|
||||
"render.image_check": "Renderanfrage prüfen",
|
||||
"render.image_supported": "Renderanfrage unterstützt; Warteschlange und Speicher werden beim Start erneut geprüft.",
|
||||
"render.image_queued": "Warten auf Rendering",
|
||||
"render.image_preparing": "Bild wird vorbereitet",
|
||||
"render.image_running": "Bild wird gerendert",
|
||||
"render.image_encoding": "PNG wird kodiert",
|
||||
"render.image_cancelling": "Wird abgebrochen",
|
||||
"render.image_cancelled": "Rendering abgebrochen",
|
||||
"render.image_completed": "Bild bereit zum Export",
|
||||
"render.image_failed": "Rendering fehlgeschlagen",
|
||||
"render.image_unknown": "Renderstatus nicht verfügbar",
|
||||
"render.image_reduce": "Kleineres Bild oder einfachere Szene versuchen.",
|
||||
"render.image_cancel": "Rendering abbrechen",
|
||||
"render.image_stale": "Dieses Bild verwendet die Szene vor den nachfolgenden Änderungen.",
|
||||
"render.image_start": "Rendering starten",
|
||||
"render.image_overwrite": "Vorhandene Datei ersetzen",
|
||||
"render.image_export": "PNG exportieren",
|
||||
"cmd.render_preflight": "Renderanfrage prüfen",
|
||||
"cmd.render_start": "Bild rendern",
|
||||
"cmd.render_status": "Renderfortschritt",
|
||||
"cmd.render_cancel": "Rendering abbrechen",
|
||||
"cmd.render_export": "PNG exportieren",
|
||||
"toolbar.image": "BILD",
|
||||
"appearance.surface_response": "Oberflächenverhalten",
|
||||
"appearance.pbr_opaque": "Opakes PBR",
|
||||
"appearance.metallic": "Metallanteil",
|
||||
"appearance.roughness": "Rauheit",
|
||||
"appearance.dielectric_ior": "Brechungsindex des Dielektrikums",
|
||||
"appearance.pbr_help": "Diese Oberfläche in Render oder einem Produkt-PNG ansehen. Keine Brechung oder Transmission.",
|
||||
"render.opaque_preview_help": "Opakes PBR und einfache Farben; Transparenz durch Blending. Brechung, HDRI und finales Rendering sind noch nicht verfügbar.",
|
||||
"render.opaque_image_summary": "Vorschau-PNG · opakes PBR · Transparenz durch Blending",
|
||||
"UNTRIM": "TRIMMEN AUFHEBEN",
|
||||
"Face": "Fläche",
|
||||
"Holes": "Löcher",
|
||||
"Holes (edges on them)": "Löcher (ihre Kanten)",
|
||||
"In place (surfaces)": "An Ort und Stelle (Flächen)",
|
||||
"None": "Keine",
|
||||
"Picked": "Ausgewählte",
|
||||
"Restore outer boundary": "Äußere Begrenzung wiederherstellen",
|
||||
"RULED SURFACE": "REGELFLÄCHE",
|
||||
"Mitre": "Gehrung",
|
||||
"Reference face": "Referenzfläche",
|
||||
"Round": "Rund",
|
||||
"Type": "Typ"
|
||||
}
|
||||
+143
-1
@@ -4,6 +4,12 @@
|
||||
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Open GitHub draft below. Your title and report will be filled in.",
|
||||
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Attach your screenshot: copy and paste it, or save and upload the PNG.",
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Click Submit new issue on GitHub to send your report.",
|
||||
"nav.touchpad.label": "Touchpad",
|
||||
"nav.touchpad.help": "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.",
|
||||
"nav.touchpad.orbit": "Orbit (hold Alt/Option and move; no mouse button)",
|
||||
"nav.touchpad.pan": "Pan (hold Shift and move; no mouse button)",
|
||||
"nav.touchpad.zoom": "Zoom (vertical scroll or pinch)",
|
||||
"nav.touchpad.scroll": "Touchpad uses vertical scroll to zoom. Your two-finger pan setting is kept for other presets.",
|
||||
"browser.body_name": "Body{number}",
|
||||
"browser.component_name": "Component{number}",
|
||||
"browser.parameters": "Parameters ({count})",
|
||||
@@ -82,6 +88,9 @@
|
||||
"cmd.doc_recovery_list": "Recoverable Designs",
|
||||
"cmd.document_inspect": "Inspect Design",
|
||||
"cmd.document_units": "Change Design Units",
|
||||
"cmd.document_metadata": "Design Properties",
|
||||
"cmd.document_metadata_set": "Change Design Properties",
|
||||
"cmd.document_metadata_remove": "Remove Design Property",
|
||||
"cmd.edit_checkpoint": "Checkpoint",
|
||||
"cmd.edit_restore_checkpoint": "Restore Checkpoint",
|
||||
"cmd.engine_commands": "List Commands",
|
||||
@@ -95,6 +104,7 @@
|
||||
"cmd.file_insert_mesh": "Insert Mesh",
|
||||
"cmd.file_insert_step": "Insert STEP",
|
||||
"cmd.file_save_mesh": "Save As Mesh",
|
||||
"cmd.file_slicers": "Installed Slicers",
|
||||
"cmd.inspect_accessibility": "Accessibility Analysis",
|
||||
"cmd.inspect_center_of_mass": "Center of Mass",
|
||||
"cmd.inspect_curvature_comb": "Curvature Comb Analysis",
|
||||
@@ -283,6 +293,7 @@
|
||||
"cmd.view_list": "List Named Views",
|
||||
"cmd.view_rename": "Rename Named View",
|
||||
"cmd.view_save": "New Named View",
|
||||
"dialog.computing": "Computing… {seconds} s",
|
||||
"dialog.custom_axis": "↖ Custom Axis",
|
||||
"dialog.edit_title": "EDIT {title}",
|
||||
"dialog.select": "↖ Select",
|
||||
@@ -317,6 +328,16 @@
|
||||
"ui.animation": "Animation",
|
||||
"ui.animation_speed": "Animation speed",
|
||||
"ui.anti_aliasing": "Anti-aliasing",
|
||||
"ui.supersampling": "Supersampling (SSAA)",
|
||||
"ui.supersampling_off": "Off (1×)",
|
||||
"ui.supersampling_adaptive": "Only when the view is still",
|
||||
"settings.supersampling_note": "Draws the view at a higher resolution and filters it down: smoother edges, lines and grid on screens below 4K. It costs GPU time: about 2.25× the pixels at 1.5× and 4× at 2×.",
|
||||
"ui.display_quality": "Display quality",
|
||||
"ui.display_quality_coarse": "Coarse",
|
||||
"ui.display_quality_normal": "Normal",
|
||||
"ui.display_quality_fine": "Fine",
|
||||
"ui.display_quality_ultra": "Ultra",
|
||||
"settings.display_quality_note": "Finer levels draw curved faces and edges smoother and take longer to mesh. Only the viewport changes, never the design or exports.",
|
||||
"ui.apply": "Apply",
|
||||
"ui.arc": "Arc",
|
||||
"ui.assemble": "Assemble",
|
||||
@@ -350,6 +371,21 @@
|
||||
"ui.coincident": "Coincident",
|
||||
"ui.collapse": "Collapse",
|
||||
"ui.collinear": "Collinear",
|
||||
"ui.colour_vision": "Colour vision",
|
||||
"ui.colour_vision_deutan": "Deutan",
|
||||
"ui.colour_vision_deutan_tip": "For a weak or missing green sense",
|
||||
"ui.colour_vision_failed": "Failed",
|
||||
"ui.colour_vision_fixed": "Fixed",
|
||||
"ui.colour_vision_mode": "Mode",
|
||||
"ui.colour_vision_note": "Changes the colours that carry a meaning: sketch states, axes, bends, analyses and icons. If you use Windows colour filters, keep this Off.",
|
||||
"ui.colour_vision_off": "Off",
|
||||
"ui.colour_vision_off_tip": "The theme's own colours",
|
||||
"ui.colour_vision_projected": "Projected",
|
||||
"ui.colour_vision_protan": "Protan",
|
||||
"ui.colour_vision_protan_tip": "For a weak or missing red sense",
|
||||
"ui.colour_vision_sample": "Sample",
|
||||
"ui.colour_vision_tritan": "Tritan",
|
||||
"ui.colour_vision_tritan_tip": "For a weak or missing blue sense",
|
||||
"ui.combine": "Combine",
|
||||
"ui.coming_later": "coming later",
|
||||
"ui.concentric": "Concentric",
|
||||
@@ -561,6 +597,10 @@
|
||||
"ui.quit": "Quit",
|
||||
"ui.radial_context_menu": "Radial context menu",
|
||||
"ui.radius": "Radius",
|
||||
"ui.pull_direction": "Pull direction",
|
||||
"ui.tool_direction": "Tool direction",
|
||||
"ui.reachable": "Reachable",
|
||||
"ui.blocked": "Blocked",
|
||||
"ui.radius_type": "Radius Type",
|
||||
"ui.rectangle": "Rectangle",
|
||||
"ui.rectangular_pattern": "Rectangular Pattern",
|
||||
@@ -575,6 +615,24 @@
|
||||
"ui.revolve": "Revolve",
|
||||
"ui.right": "Right",
|
||||
"ui.save": "Save",
|
||||
"ui.heading_save_mesh": "SAVE AS MESH",
|
||||
"ui.format": "Format",
|
||||
"ui.mesh_format_stl": "STL",
|
||||
"ui.mesh_format_3mf": "3MF",
|
||||
"ui.mesh_format_3mf_tagged": "3MF with face tags",
|
||||
"ui.send_to_slicer": "Send to slicer",
|
||||
"ui.slicer": "Slicer",
|
||||
"ui.custom_program": "Custom…",
|
||||
"ui.no_slicer_found": "No slicer found",
|
||||
"ui.send": "Send",
|
||||
"ui.custom_program_tip": "Choose a slicer program to add to the list",
|
||||
"ui.slicer_delete_tip": "{program}\nPress Delete to remove it from the list",
|
||||
"ui.save_mesh_pick_bodies": "Select the bodies to save",
|
||||
"ui.save_mesh_no_slicer": "No slicer found: choose Custom… and pick your slicer program, or turn off Send to slicer",
|
||||
"ui.not_a_program": "{path} is not a program that can be started",
|
||||
"ui.sent_to_slicer": "Sent to {slicer}",
|
||||
"ui.saved_to": "Saved {path}",
|
||||
"ui.save_mesh_temp_failed": "Couldn't make {folder}: {error}",
|
||||
"ui.save_as": "Save As",
|
||||
"ui.save_as_ellipsis": "Save As...",
|
||||
"ui.scale": "Scale",
|
||||
@@ -729,6 +787,8 @@
|
||||
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Files from many CAD programs have Y up, so they open lying on their back here (Z up).",
|
||||
"Filter": "Filter",
|
||||
"Find in Browser": "Find in Browser",
|
||||
"Copy Error Message": "Copy Error Message",
|
||||
"Copy Warning Message": "Copy Warning Message",
|
||||
"Flat size": "Flat size",
|
||||
"Follow camera (Look At sketches)": "Follow camera (Look At sketches)",
|
||||
"Font": "Font",
|
||||
@@ -917,6 +977,17 @@
|
||||
"ui.pinToShortcuts": "Pin to Shortcuts",
|
||||
"ui.pinToToolbar": "Pin to Toolbar",
|
||||
"ui.properties": "Properties",
|
||||
"ui.design_properties.project": "Project",
|
||||
"ui.design_properties.part_number": "Part number",
|
||||
"ui.design_properties.revision": "Revision",
|
||||
"ui.design_properties.author": "Author",
|
||||
"ui.design_properties.organization": "Organization",
|
||||
"ui.design_properties.description": "Description",
|
||||
"ui.design_properties.license": "License",
|
||||
"ui.design_properties.not_text": "Saved as something other than text; typing here replaces it.",
|
||||
"ui.design_properties.exports": "STEP, IGES and 3MF exports carry these where the format has a place for them.",
|
||||
"ui.design_properties.others": "Other tools' data",
|
||||
"ui.design_properties.others_note": "Kept in the design as it is and never exported.",
|
||||
"ui.removeSection": "Remove Section",
|
||||
"ui.removeFromToolbar": "Remove from Toolbar",
|
||||
"ui.repeat": "Repeat",
|
||||
@@ -940,6 +1011,9 @@
|
||||
"ui.updateToCurrentView": "Update to Current View",
|
||||
"ui.zoom": "Zoom",
|
||||
"ui.command_done": "{command} done",
|
||||
"drive.stopped_by_loop": "Stopped at {value}: the loop through {joint} can't close any further",
|
||||
"drive.stopped_by_contact": "Stopped at {value}: {a} would pass through {b}",
|
||||
"drive.unconnected": "Moves nothing: {part} is not connected to anything grounded",
|
||||
"timeline.group": "New Feature Folder",
|
||||
"timeline.ungroup": "Remove Feature Folders",
|
||||
"timeline.rename_group": "Rename Feature Folder",
|
||||
@@ -1011,5 +1085,73 @@
|
||||
"update.restart_later": "Later",
|
||||
"update.installer_started": "The installer starts when SolidCraft closes.",
|
||||
"update.dmg_opened": "The disk image is open and verified: drag SolidCraft to Applications, replacing the old copy, then restart SolidCraft.",
|
||||
"update.install_failed": "The update didn't install: {error}. Nothing was changed; Download gets the package instead."
|
||||
"update.install_failed": "The update didn't install: {error}. Nothing was changed; Download gets the package instead.",
|
||||
"cmd.render_settings_get": "Render Settings",
|
||||
"cmd.render_settings_set": "Save Render Settings",
|
||||
"cmd.render_scene": "Inspect Render Scene",
|
||||
"cmd.render_settings_apply": "Apply Render Camera",
|
||||
"toolbar.render": "RENDER",
|
||||
"toolbar.render_scene": "SCENE",
|
||||
"render.preview": "Integrated preview",
|
||||
"render.preview_help": "Raster shading and blended transparency. Refraction, HDRI and final rendering are not available yet.",
|
||||
"render.setup_name": "Setup name",
|
||||
"render.background": "Scene background",
|
||||
"render.perspective": "Perspective camera",
|
||||
"render.fit": "Fit Product",
|
||||
"render.restore_camera": "Restore Saved Camera",
|
||||
"render.save_setup": "Save Setup",
|
||||
"render.unsaved": "Unsaved preview changes",
|
||||
"render.reload_settings": "Reload Saved Settings",
|
||||
"render.unavailable_preview": "This setup requests an unavailable preview profile.",
|
||||
"render.use_preview": "Use Integrated Preview",
|
||||
"render.navigation_help": "Drag to orbit; middle-drag to pan; scroll to zoom. Save Setup records the current camera.",
|
||||
"render.return_design": "Return to Design",
|
||||
"render.image_title": "Render Image",
|
||||
"render.image_help": "CPU preview image · PNG · approximate transparency",
|
||||
"render.image_width": "Width",
|
||||
"render.image_height": "Height",
|
||||
"render.image_transparent": "Transparent background",
|
||||
"render.image_check": "Check Render Request",
|
||||
"render.image_supported": "Render request is supported; the queue and memory pool are checked again at start.",
|
||||
"render.image_queued": "Waiting to render",
|
||||
"render.image_preparing": "Preparing image",
|
||||
"render.image_running": "Rendering image",
|
||||
"render.image_encoding": "Encoding PNG",
|
||||
"render.image_cancelling": "Cancelling",
|
||||
"render.image_cancelled": "Render cancelled",
|
||||
"render.image_completed": "Image ready to export",
|
||||
"render.image_failed": "Render failed",
|
||||
"render.image_unknown": "Render status unavailable",
|
||||
"render.image_reduce": "Try a smaller image or simpler scene.",
|
||||
"render.image_cancel": "Cancel Render",
|
||||
"render.image_stale": "This image uses the scene captured before subsequent changes.",
|
||||
"render.image_start": "Start Render",
|
||||
"render.image_overwrite": "Replace existing file",
|
||||
"render.image_export": "Export PNG",
|
||||
"cmd.render_preflight": "Check Render Request",
|
||||
"cmd.render_start": "Render Image",
|
||||
"cmd.render_status": "Render Progress",
|
||||
"cmd.render_cancel": "Cancel Render",
|
||||
"cmd.render_export": "Export Render Image",
|
||||
"toolbar.image": "IMAGE",
|
||||
"appearance.surface_response": "Surface response",
|
||||
"appearance.pbr_opaque": "Opaque PBR",
|
||||
"appearance.metallic": "Metallic",
|
||||
"appearance.roughness": "Roughness",
|
||||
"appearance.dielectric_ior": "Dielectric IOR",
|
||||
"appearance.pbr_help": "View this surface in Render or a product PNG. No refraction or transmission.",
|
||||
"render.opaque_preview_help": "Opaque PBR and simple colors; blended transparency. Refraction, HDRI and final rendering are not available yet.",
|
||||
"render.opaque_image_summary": "Preview PNG · opaque PBR · blended transparency",
|
||||
"UNTRIM": "UNTRIM",
|
||||
"Face": "Face",
|
||||
"Holes": "Holes",
|
||||
"Holes (edges on them)": "Holes (edges on them)",
|
||||
"In place (surfaces)": "In place (surfaces)",
|
||||
"None": "None",
|
||||
"Picked": "Picked",
|
||||
"Restore outer boundary": "Restore outer boundary",
|
||||
"RULED SURFACE": "RULED SURFACE",
|
||||
"Mitre": "Mitre",
|
||||
"Reference face": "Reference face",
|
||||
"Round": "Round"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+143
-1
@@ -4,6 +4,12 @@
|
||||
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 下のボタンでGitHubの下書きを開きます。タイトルとレポートは入力済みです。",
|
||||
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. スクリーンショットを添付します。コピーして貼り付けるか、PNGを保存してアップロードします。",
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. GitHubでSubmit new issueをクリックしてレポートを送信します。",
|
||||
"nav.touchpad.label": "タッチパッド",
|
||||
"nav.touchpad.help": "Shift を押しながらポインターを動かすとパン、Alt/Option を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。",
|
||||
"nav.touchpad.orbit": "回転(Alt/Option を押しながら移動、マウスボタン不要)",
|
||||
"nav.touchpad.pan": "パン(Shift を押しながら移動、マウスボタン不要)",
|
||||
"nav.touchpad.zoom": "ズーム(縦スクロールまたはピンチ)",
|
||||
"nav.touchpad.scroll": "タッチパッドでは縦スクロールでズームします。2 本指パンの設定は他のプリセット用に保持されます。",
|
||||
"browser.body_name": "ボディ{number}",
|
||||
"browser.component_name": "コンポーネント{number}",
|
||||
"browser.parameters": "パラメータ ({count})",
|
||||
@@ -82,6 +88,9 @@
|
||||
"cmd.doc_recovery_list": "復元可能なデザイン",
|
||||
"cmd.document_inspect": "デザインを検査",
|
||||
"cmd.document_units": "デザインの単位を変更",
|
||||
"cmd.document_metadata": "デザインのプロパティ",
|
||||
"cmd.document_metadata_set": "デザインのプロパティを変更",
|
||||
"cmd.document_metadata_remove": "デザインのプロパティを削除",
|
||||
"cmd.edit_checkpoint": "チェックポイント",
|
||||
"cmd.edit_restore_checkpoint": "チェックポイントを復元",
|
||||
"cmd.engine_commands": "コマンドを一覧表示",
|
||||
@@ -95,6 +104,7 @@
|
||||
"cmd.file_insert_mesh": "メッシュを挿入",
|
||||
"cmd.file_insert_step": "STEP を挿入",
|
||||
"cmd.file_save_mesh": "メッシュとして保存",
|
||||
"cmd.file_slicers": "インストール済みのスライサー",
|
||||
"cmd.inspect_accessibility": "アクセシビリティ解析",
|
||||
"cmd.inspect_center_of_mass": "重心",
|
||||
"cmd.inspect_curvature_comb": "曲率コーム解析",
|
||||
@@ -283,6 +293,7 @@
|
||||
"cmd.view_list": "名前付きビューを一覧表示",
|
||||
"cmd.view_rename": "名前付きビューの名前を変更",
|
||||
"cmd.view_save": "新規名前付きビュー",
|
||||
"dialog.computing": "計算中… {seconds} 秒",
|
||||
"dialog.custom_axis": "↖ カスタム軸",
|
||||
"dialog.edit_title": "{title}を編集",
|
||||
"dialog.select": "↖ 選択",
|
||||
@@ -317,6 +328,16 @@
|
||||
"ui.animation": "アニメーション",
|
||||
"ui.animation_speed": "アニメーション速度",
|
||||
"ui.anti_aliasing": "アンチエイリアス",
|
||||
"ui.supersampling": "スーパーサンプリング (SSAA)",
|
||||
"ui.supersampling_off": "オフ (1×)",
|
||||
"ui.supersampling_adaptive": "ビューが静止しているときのみ",
|
||||
"settings.supersampling_note": "ビューを高い解像度で描画してから縮小します。4K 未満の画面でエッジ、線、グリッドが滑らかになります。GPU の負荷が増えます(1.5× で約 2.25 倍、2× で 4 倍のピクセル)。",
|
||||
"ui.display_quality": "表示品質",
|
||||
"ui.display_quality_coarse": "粗い",
|
||||
"ui.display_quality_normal": "標準",
|
||||
"ui.display_quality_fine": "細かい",
|
||||
"ui.display_quality_ultra": "最高",
|
||||
"settings.display_quality_note": "細かいほど曲面とエッジが滑らかに表示されますが、メッシュの生成に時間がかかります。変わるのはビューポートだけで、設計やエクスポートは変わりません。",
|
||||
"ui.apply": "適用",
|
||||
"ui.arc": "円弧",
|
||||
"ui.assemble": "アセンブリ",
|
||||
@@ -350,6 +371,21 @@
|
||||
"ui.coincident": "一致",
|
||||
"ui.collapse": "折りたたむ",
|
||||
"ui.collinear": "同一直線上",
|
||||
"ui.colour_vision": "色覚",
|
||||
"ui.colour_vision_deutan": "2型色覚(D型)",
|
||||
"ui.colour_vision_deutan_tip": "緑を感じにくい、または感じない場合に",
|
||||
"ui.colour_vision_failed": "エラー",
|
||||
"ui.colour_vision_fixed": "固定",
|
||||
"ui.colour_vision_mode": "モード",
|
||||
"ui.colour_vision_note": "意味を持つ色を変更します(スケッチの状態、軸、曲げ、解析、アイコン)。Windows のカラー フィルターを使う場合は「オフ」のままにしてください。",
|
||||
"ui.colour_vision_off": "オフ",
|
||||
"ui.colour_vision_off_tip": "テーマ本来の色",
|
||||
"ui.colour_vision_projected": "投影",
|
||||
"ui.colour_vision_protan": "1型色覚(P型)",
|
||||
"ui.colour_vision_protan_tip": "赤を感じにくい、または感じない場合に",
|
||||
"ui.colour_vision_sample": "サンプル",
|
||||
"ui.colour_vision_tritan": "3型色覚(T型)",
|
||||
"ui.colour_vision_tritan_tip": "青を感じにくい、または感じない場合に",
|
||||
"ui.combine": "結合",
|
||||
"ui.coming_later": "準備中",
|
||||
"ui.concentric": "同心",
|
||||
@@ -561,6 +597,10 @@
|
||||
"ui.quit": "終了",
|
||||
"ui.radial_context_menu": "パイ メニュー",
|
||||
"ui.radius": "半径",
|
||||
"ui.pull_direction": "抜き方向",
|
||||
"ui.tool_direction": "工具方向",
|
||||
"ui.reachable": "到達可能",
|
||||
"ui.blocked": "到達不可",
|
||||
"ui.radius_type": "半径タイプ",
|
||||
"ui.rectangle": "長方形",
|
||||
"ui.rectangular_pattern": "矩形パターン",
|
||||
@@ -575,6 +615,24 @@
|
||||
"ui.revolve": "回転",
|
||||
"ui.right": "右側面",
|
||||
"ui.save": "保存",
|
||||
"ui.heading_save_mesh": "メッシュとして保存",
|
||||
"ui.format": "形式",
|
||||
"ui.mesh_format_stl": "STL",
|
||||
"ui.mesh_format_3mf": "3MF",
|
||||
"ui.mesh_format_3mf_tagged": "面タグ付き 3MF",
|
||||
"ui.send_to_slicer": "スライサーに送る",
|
||||
"ui.slicer": "スライサー",
|
||||
"ui.custom_program": "カスタム…",
|
||||
"ui.no_slicer_found": "スライサーが見つかりません",
|
||||
"ui.send": "送信",
|
||||
"ui.custom_program_tip": "リストに追加するスライサーのプログラムを選択",
|
||||
"ui.slicer_delete_tip": "{program}\nDelete キーでリストから削除",
|
||||
"ui.save_mesh_pick_bodies": "保存するボディを選択してください",
|
||||
"ui.save_mesh_no_slicer": "スライサーが見つかりません。「カスタム…」でスライサーのプログラムを選ぶか、「スライサーに送る」をオフにしてください",
|
||||
"ui.not_a_program": "{path} は起動できるプログラムではありません",
|
||||
"ui.sent_to_slicer": "{slicer} に送信しました",
|
||||
"ui.saved_to": "保存しました: {path}",
|
||||
"ui.save_mesh_temp_failed": "{folder} を作成できません: {error}",
|
||||
"ui.save_as": "名前を付けて保存",
|
||||
"ui.save_as_ellipsis": "名前を付けて保存…",
|
||||
"ui.scale": "尺度",
|
||||
@@ -729,6 +787,8 @@
|
||||
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "多くのCADではY軸を上向きとしているため、Z軸を上向きとするSolidCraftでは、読み込んだモデルが横倒しの向きで表示されることがあります。",
|
||||
"Filter": "フィルター",
|
||||
"Find in Browser": "ブラウザで検索",
|
||||
"Copy Error Message": "エラーメッセージをコピー",
|
||||
"Copy Warning Message": "警告メッセージをコピー",
|
||||
"Flat size": "展開サイズ",
|
||||
"Follow camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
|
||||
"Font": "フォント",
|
||||
@@ -917,6 +977,17 @@
|
||||
"ui.pinToShortcuts": "ショートカットに固定",
|
||||
"ui.pinToToolbar": "ツールバーに固定",
|
||||
"ui.properties": "プロパティ",
|
||||
"ui.design_properties.project": "プロジェクト",
|
||||
"ui.design_properties.part_number": "部品番号",
|
||||
"ui.design_properties.revision": "リビジョン",
|
||||
"ui.design_properties.author": "作成者",
|
||||
"ui.design_properties.organization": "組織",
|
||||
"ui.design_properties.description": "説明",
|
||||
"ui.design_properties.license": "ライセンス",
|
||||
"ui.design_properties.not_text": "テキスト以外の値で保存されています。ここに入力すると置き換わります。",
|
||||
"ui.design_properties.exports": "STEP、IGES、3MF のエクスポートでは、形式に対応する項目があればこれらが書き込まれます。",
|
||||
"ui.design_properties.others": "他のツールのデータ",
|
||||
"ui.design_properties.others_note": "デザイン内にそのまま保持され、エクスポートされることはありません。",
|
||||
"ui.removeSection": "セクションを解除",
|
||||
"ui.removeFromToolbar": "ツールバーから削除",
|
||||
"ui.repeat": "繰り返し",
|
||||
@@ -940,6 +1011,9 @@
|
||||
"ui.updateToCurrentView": "現在のビューに更新",
|
||||
"ui.zoom": "ズーム",
|
||||
"ui.command_done": "{command} 完了",
|
||||
"drive.stopped_by_loop": "{value} で停止: {joint} を通るループはこれ以上閉じられません",
|
||||
"drive.stopped_by_contact": "{value} で停止: {a} が {b} を通り抜けてしまいます",
|
||||
"drive.unconnected": "何も動きません: {part} は空間固定されたものに接続されていません",
|
||||
"timeline.group": "フィーチャーフォルダを新規作成",
|
||||
"timeline.ungroup": "フィーチャーフォルダを削除",
|
||||
"timeline.rename_group": "フィーチャーフォルダの名前を変更",
|
||||
@@ -1011,5 +1085,73 @@
|
||||
"update.restart_later": "後で",
|
||||
"update.installer_started": "SolidCraft の終了後にインストーラーが起動します。",
|
||||
"update.dmg_opened": "ディスクイメージを検証して開きました: SolidCraft をアプリケーションフォルダにドラッグして古いものを置き換え、SolidCraft を再起動してください。",
|
||||
"update.install_failed": "アップデートをインストールできませんでした: {error}。何も変更されていません。「ダウンロード」でパッケージを入手できます。"
|
||||
"update.install_failed": "アップデートをインストールできませんでした: {error}。何も変更されていません。「ダウンロード」でパッケージを入手できます。",
|
||||
"cmd.render_settings_get": "レンダリング設定",
|
||||
"cmd.render_settings_set": "レンダリング設定を保存",
|
||||
"cmd.render_scene": "レンダリングシーンを確認",
|
||||
"cmd.render_settings_apply": "レンダリングカメラを適用",
|
||||
"toolbar.render": "レンダリング",
|
||||
"toolbar.render_scene": "シーン",
|
||||
"render.preview": "統合プレビュー",
|
||||
"render.preview_help": "ラスタシェーディングとブレンド透明度を使用します。屈折、HDRI、最終レンダリングはまだ利用できません。",
|
||||
"render.setup_name": "セットアップ名",
|
||||
"render.background": "シーンの背景",
|
||||
"render.perspective": "透視投影カメラ",
|
||||
"render.fit": "製品を画面に合わせる",
|
||||
"render.restore_camera": "保存したカメラを復元",
|
||||
"render.save_setup": "セットアップを保存",
|
||||
"render.unsaved": "未保存のプレビュー変更",
|
||||
"render.reload_settings": "保存した設定を再読み込み",
|
||||
"render.unavailable_preview": "このセットアップは利用できないプレビュープロファイルを要求しています。",
|
||||
"render.use_preview": "統合プレビューを使用",
|
||||
"render.navigation_help": "ドラッグで回転、中央ボタンのドラッグで移動、スクロールでズームします。「セットアップを保存」で現在のカメラを記録します。",
|
||||
"render.return_design": "設計に戻る",
|
||||
"render.image_title": "画像をレンダリング",
|
||||
"render.image_help": "CPUプレビュー画像 · PNG · 近似透過",
|
||||
"render.image_width": "幅",
|
||||
"render.image_height": "高さ",
|
||||
"render.image_transparent": "透明な背景",
|
||||
"render.image_check": "レンダリング要求を確認",
|
||||
"render.image_supported": "レンダリング要求に対応しています。開始時にキューとメモリを再確認します。",
|
||||
"render.image_queued": "レンダリング待機中",
|
||||
"render.image_preparing": "画像を準備中",
|
||||
"render.image_running": "画像をレンダリング中",
|
||||
"render.image_encoding": "PNGをエンコード中",
|
||||
"render.image_cancelling": "キャンセル中",
|
||||
"render.image_cancelled": "レンダリングをキャンセルしました",
|
||||
"render.image_completed": "画像をエクスポートできます",
|
||||
"render.image_failed": "レンダリングに失敗しました",
|
||||
"render.image_unknown": "レンダリング状態を取得できません",
|
||||
"render.image_reduce": "画像を小さくするかシーンを簡略化してください。",
|
||||
"render.image_cancel": "レンダリングをキャンセル",
|
||||
"render.image_stale": "この画像は後続の変更前に取得したシーンを使用しています。",
|
||||
"render.image_start": "レンダリングを開始",
|
||||
"render.image_overwrite": "既存のファイルを置換",
|
||||
"render.image_export": "PNGをエクスポート",
|
||||
"cmd.render_preflight": "レンダリング要求を確認",
|
||||
"cmd.render_start": "画像をレンダリング",
|
||||
"cmd.render_status": "レンダリングの進行状況",
|
||||
"cmd.render_cancel": "レンダリングをキャンセル",
|
||||
"cmd.render_export": "PNGをエクスポート",
|
||||
"toolbar.image": "画像",
|
||||
"appearance.surface_response": "表面の応答",
|
||||
"appearance.pbr_opaque": "不透明PBR",
|
||||
"appearance.metallic": "金属度",
|
||||
"appearance.roughness": "粗さ",
|
||||
"appearance.dielectric_ior": "誘電体の屈折率",
|
||||
"appearance.pbr_help": "この表面はレンダリング作業空間または製品PNGで確認できます。屈折や透過には対応していません。",
|
||||
"render.opaque_preview_help": "不透明PBRと単色表示、アルファ合成による透明表示に対応します。屈折、HDRI、最終レンダリングにはまだ対応していません。",
|
||||
"render.opaque_image_summary": "プレビューPNG · 不透明PBR · アルファ合成の透明表示",
|
||||
"UNTRIM": "トリム解除",
|
||||
"Face": "面",
|
||||
"Holes": "穴",
|
||||
"Holes (edges on them)": "穴(そのエッジ)",
|
||||
"In place (surfaces)": "その場で(サーフェス)",
|
||||
"None": "なし",
|
||||
"Picked": "選択したもの",
|
||||
"Restore outer boundary": "外側の境界を復元",
|
||||
"RULED SURFACE": "ルールドサーフェス",
|
||||
"Mitre": "留め継ぎ",
|
||||
"Reference face": "参照面",
|
||||
"Round": "丸め"
|
||||
}
|
||||
+145
-1
@@ -4,12 +4,19 @@
|
||||
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 아래에서 GitHub 초안을 여세요. 제목과 보고서가 자동으로 입력됩니다.",
|
||||
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 스크린샷을 첨부하세요. 복사하여 붙여넣거나 PNG로 저장한 뒤 업로드하세요.",
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. GitHub에서 Submit new issue를 클릭하여 보고서를 보내세요.",
|
||||
"nav.touchpad.label": "터치패드",
|
||||
"nav.touchpad.help": "Shift를 누른 채 포인터를 움직이면 화면이 이동하고, Alt/Option을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.",
|
||||
"nav.touchpad.orbit": "회전 (Alt/Option을 누르고 이동, 마우스 버튼 불필요)",
|
||||
"nav.touchpad.pan": "화면 이동 (Shift를 누르고 이동, 마우스 버튼 불필요)",
|
||||
"nav.touchpad.zoom": "확대/축소 (세로 스크롤 또는 핀치)",
|
||||
"nav.touchpad.scroll": "터치패드는 세로 스크롤로 확대/축소합니다. 두 손가락 이동 설정은 다른 프리셋을 위해 유지됩니다.",
|
||||
"browser.body_name": "바디{number}",
|
||||
"browser.component_name": "컴포넌트{number}",
|
||||
"browser.parameters": "매개변수 ({count})",
|
||||
"browser.precision": "정밀도: {precision}",
|
||||
"browser.sketch_name": "스케치{number}",
|
||||
"browser.units": "단위: {units}",
|
||||
"dialog.computing": "계산 중… {seconds}초",
|
||||
"dialog.custom_axis": "↖ 사용자 정의 축",
|
||||
"dialog.edit_title": "{title} 편집",
|
||||
"dialog.select": "↖ 선택",
|
||||
@@ -42,6 +49,16 @@
|
||||
"ui.animation": "애니메이션",
|
||||
"ui.animation_speed": "애니메이션 속도",
|
||||
"ui.anti_aliasing": "안티앨리어싱",
|
||||
"ui.supersampling": "슈퍼샘플링 (SSAA)",
|
||||
"ui.supersampling_off": "끔 (1×)",
|
||||
"ui.supersampling_adaptive": "뷰가 멈춰 있을 때만",
|
||||
"settings.supersampling_note": "뷰를 더 높은 해상도로 그린 뒤 축소합니다. 4K 미만 화면에서 모서리, 선, 그리드가 더 매끄러워집니다. GPU 시간이 듭니다: 1.5×에서 약 2.25배, 2×에서 4배의 픽셀.",
|
||||
"ui.display_quality": "표시 품질",
|
||||
"ui.display_quality_coarse": "거침",
|
||||
"ui.display_quality_normal": "보통",
|
||||
"ui.display_quality_fine": "세밀",
|
||||
"ui.display_quality_ultra": "최고",
|
||||
"settings.display_quality_note": "세밀할수록 곡면과 모서리가 더 매끄럽게 표시되지만 메시 생성 시간이 늘어납니다. 뷰포트만 바뀌며 설계나 내보내기는 바뀌지 않습니다.",
|
||||
"ui.apply": "적용",
|
||||
"ui.arc": "호",
|
||||
"ui.assemble": "조립",
|
||||
@@ -72,6 +89,21 @@
|
||||
"ui.coincident": "일치",
|
||||
"ui.collapse": "접기",
|
||||
"ui.collinear": "동일 선상",
|
||||
"ui.colour_vision": "색각",
|
||||
"ui.colour_vision_deutan": "녹색각이상 (Deutan)",
|
||||
"ui.colour_vision_deutan_tip": "녹색 감각이 약하거나 없는 경우",
|
||||
"ui.colour_vision_failed": "실패",
|
||||
"ui.colour_vision_fixed": "고정",
|
||||
"ui.colour_vision_mode": "모드",
|
||||
"ui.colour_vision_note": "의미를 나타내는 색을 바꿉니다: 스케치 상태, 축, 벤딩, 분석, 아이콘. Windows 색 필터를 사용한다면 '끄기'로 두세요.",
|
||||
"ui.colour_vision_off": "끄기",
|
||||
"ui.colour_vision_off_tip": "테마 고유의 색상",
|
||||
"ui.colour_vision_projected": "투영",
|
||||
"ui.colour_vision_protan": "적색각이상 (Protan)",
|
||||
"ui.colour_vision_protan_tip": "빨간색 감각이 약하거나 없는 경우",
|
||||
"ui.colour_vision_sample": "예시",
|
||||
"ui.colour_vision_tritan": "청색각이상 (Tritan)",
|
||||
"ui.colour_vision_tritan_tip": "파란색 감각이 약하거나 없는 경우",
|
||||
"ui.combine": "결합",
|
||||
"ui.coming_later": "추후 지원 예정",
|
||||
"ui.concentric": "동심",
|
||||
@@ -265,6 +297,10 @@
|
||||
"ui.quantity": "수량",
|
||||
"ui.quit": "종료",
|
||||
"ui.radius": "반지름",
|
||||
"ui.pull_direction": "빼기 방향",
|
||||
"ui.tool_direction": "공구 방향",
|
||||
"ui.reachable": "도달 가능",
|
||||
"ui.blocked": "막힘",
|
||||
"ui.radius_type": "반지름 유형",
|
||||
"ui.rectangle": "직사각형",
|
||||
"ui.rectangular_pattern": "직사각형 패턴",
|
||||
@@ -276,6 +312,24 @@
|
||||
"ui.revolve": "회전",
|
||||
"ui.right": "우측면",
|
||||
"ui.save": "저장",
|
||||
"ui.heading_save_mesh": "메쉬로 저장",
|
||||
"ui.format": "형식",
|
||||
"ui.mesh_format_stl": "STL",
|
||||
"ui.mesh_format_3mf": "3MF",
|
||||
"ui.mesh_format_3mf_tagged": "면 태그가 있는 3MF",
|
||||
"ui.send_to_slicer": "슬라이서로 보내기",
|
||||
"ui.slicer": "슬라이서",
|
||||
"ui.custom_program": "사용자 지정…",
|
||||
"ui.no_slicer_found": "슬라이서를 찾을 수 없음",
|
||||
"ui.send": "보내기",
|
||||
"ui.custom_program_tip": "목록에 추가할 슬라이서 프로그램 선택",
|
||||
"ui.slicer_delete_tip": "{program}\nDelete 키로 목록에서 제거",
|
||||
"ui.save_mesh_pick_bodies": "저장할 바디를 선택하세요",
|
||||
"ui.save_mesh_no_slicer": "슬라이서를 찾을 수 없습니다. '사용자 지정…'에서 슬라이서 프로그램을 선택하거나 '슬라이서로 보내기'를 끄세요",
|
||||
"ui.not_a_program": "{path}은(는) 실행할 수 있는 프로그램이 아닙니다",
|
||||
"ui.sent_to_slicer": "{slicer}(으)로 보냈습니다",
|
||||
"ui.saved_to": "저장됨: {path}",
|
||||
"ui.save_mesh_temp_failed": "{folder}을(를) 만들 수 없습니다: {error}",
|
||||
"ui.save_as": "다른 이름으로 저장",
|
||||
"ui.save_as_ellipsis": "다른 이름으로 저장...",
|
||||
"ui.scale": "축척",
|
||||
@@ -366,6 +420,17 @@
|
||||
"ui.pinToShortcuts": "바로 가기에 고정",
|
||||
"ui.pinToToolbar": "도구 모음에 고정",
|
||||
"ui.properties": "속성",
|
||||
"ui.design_properties.project": "프로젝트",
|
||||
"ui.design_properties.part_number": "부품 번호",
|
||||
"ui.design_properties.revision": "리비전",
|
||||
"ui.design_properties.author": "작성자",
|
||||
"ui.design_properties.organization": "조직",
|
||||
"ui.design_properties.description": "설명",
|
||||
"ui.design_properties.license": "라이선스",
|
||||
"ui.design_properties.not_text": "텍스트가 아닌 값으로 저장되어 있습니다. 여기에 입력하면 대체됩니다.",
|
||||
"ui.design_properties.exports": "STEP, IGES, 3MF 내보내기는 형식에 해당 항목이 있으면 이 속성을 기록합니다.",
|
||||
"ui.design_properties.others": "다른 도구의 데이터",
|
||||
"ui.design_properties.others_note": "설계에 그대로 유지되며 내보내지지 않습니다.",
|
||||
"ui.removeSection": "단면 제거",
|
||||
"ui.removeFromToolbar": "도구 모음에서 제거",
|
||||
"ui.repeat": "반복",
|
||||
@@ -389,6 +454,9 @@
|
||||
"ui.updateToCurrentView": "현재 보기로 업데이트",
|
||||
"ui.zoom": "확대/축소",
|
||||
"ui.command_done": "{command} 완료",
|
||||
"drive.stopped_by_loop": "{value}에서 멈춤: {joint}을(를) 지나는 루프를 더 이상 닫을 수 없습니다",
|
||||
"drive.stopped_by_contact": "{value}에서 멈춤: {a}이(가) {b}을(를) 통과하게 됩니다",
|
||||
"drive.unconnected": "아무것도 움직이지 않습니다: {part}이(가) 고정된 것에 연결되어 있지 않습니다",
|
||||
"cmd.appearance_assign": "외관",
|
||||
"cmd.appearance_library": "외관 라이브러리",
|
||||
"cmd.appearance_list": "외관 목록",
|
||||
@@ -461,6 +529,9 @@
|
||||
"cmd.doc_recovery_list": "복구 가능한 설계",
|
||||
"cmd.document_inspect": "설계 검사",
|
||||
"cmd.document_units": "설계 단위 변경",
|
||||
"cmd.document_metadata": "설계 속성",
|
||||
"cmd.document_metadata_set": "설계 속성 변경",
|
||||
"cmd.document_metadata_remove": "설계 속성 제거",
|
||||
"cmd.edit_checkpoint": "체크포인트",
|
||||
"cmd.edit_restore_checkpoint": "체크포인트 복원",
|
||||
"cmd.engine_commands": "명령 목록",
|
||||
@@ -474,6 +545,7 @@
|
||||
"cmd.file_insert_mesh": "메쉬 삽입",
|
||||
"cmd.file_insert_step": "STEP 삽입",
|
||||
"cmd.file_save_mesh": "메쉬로 저장",
|
||||
"cmd.file_slicers": "설치된 슬라이서",
|
||||
"cmd.inspect_accessibility": "접근성 분석",
|
||||
"cmd.inspect_center_of_mass": "무게중심",
|
||||
"cmd.inspect_curvature_comb": "곡률빗 분석",
|
||||
@@ -668,9 +740,12 @@
|
||||
"ui.heading_constraints": "구속 조건",
|
||||
"ui.heading_utilities": "유틸리티",
|
||||
"BROWSER": "브라우저",
|
||||
"Bends": "벤딩",
|
||||
"Drive Joint": "조인트 구동",
|
||||
"Edit Profile Sketch": "윤곽 스케치 편집",
|
||||
"Find in Browser": "브라우저에서 찾기",
|
||||
"Copy Error Message": "오류 메시지 복사",
|
||||
"Copy Warning Message": "경고 메시지 복사",
|
||||
"Report a bug — review a screenshot and open a GitHub draft": "버그 신고 — 스크린샷을 검토하고 GitHub 초안 열기",
|
||||
"Reset Toolbar": "도구 모음 재설정",
|
||||
"Select bodies (a face picks its body)": "바디 선택(면을 클릭하면 해당 바디 선택)",
|
||||
@@ -752,5 +827,74 @@
|
||||
"update.restart_later": "나중에",
|
||||
"update.installer_started": "SolidCraft가 종료되면 설치 프로그램이 시작됩니다.",
|
||||
"update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: SolidCraft를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 SolidCraft를 다시 시작하세요.",
|
||||
"update.install_failed": "업데이트를 설치하지 못했습니다: {error}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다."
|
||||
"update.install_failed": "업데이트를 설치하지 못했습니다: {error}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다.",
|
||||
"cmd.render_settings_get": "렌더링 설정",
|
||||
"cmd.render_settings_set": "렌더링 설정 저장",
|
||||
"cmd.render_scene": "렌더링 장면 검사",
|
||||
"cmd.render_settings_apply": "렌더링 카메라 적용",
|
||||
"toolbar.render": "렌더링",
|
||||
"toolbar.render_scene": "장면",
|
||||
"render.preview": "통합 미리보기",
|
||||
"render.preview_help": "래스터 셰이딩과 블렌딩 투명도를 사용합니다. 굴절, HDRI 및 최종 렌더링은 아직 지원하지 않습니다.",
|
||||
"render.setup_name": "설정 이름",
|
||||
"render.background": "장면 배경",
|
||||
"render.perspective": "원근 카메라",
|
||||
"render.fit": "제품에 맞추기",
|
||||
"render.restore_camera": "저장된 카메라 복원",
|
||||
"render.save_setup": "설정 저장",
|
||||
"render.unsaved": "저장하지 않은 미리보기 변경 사항",
|
||||
"render.reload_settings": "저장된 설정 다시 불러오기",
|
||||
"render.unavailable_preview": "이 설정은 사용할 수 없는 미리보기 프로필을 요청합니다.",
|
||||
"render.use_preview": "통합 미리보기 사용",
|
||||
"render.navigation_help": "드래그하여 회전하고, 가운데 버튼으로 드래그하여 이동하며, 스크롤하여 확대/축소합니다. 설정 저장은 현재 카메라를 기록합니다.",
|
||||
"render.return_design": "설계로 돌아가기",
|
||||
"render.image_title": "이미지 렌더링",
|
||||
"render.image_help": "CPU 미리 보기 이미지 · PNG · 근사 투명도",
|
||||
"render.image_width": "너비",
|
||||
"render.image_height": "높이",
|
||||
"render.image_transparent": "투명 배경",
|
||||
"render.image_check": "렌더 요청 확인",
|
||||
"render.image_supported": "지원되는 렌더 요청입니다. 시작 시 대기열과 메모리 풀을 다시 확인합니다.",
|
||||
"render.image_queued": "렌더링 대기 중",
|
||||
"render.image_preparing": "이미지 준비 중",
|
||||
"render.image_running": "이미지 렌더링 중",
|
||||
"render.image_encoding": "PNG 인코딩 중",
|
||||
"render.image_cancelling": "취소 중",
|
||||
"render.image_cancelled": "렌더링 취소됨",
|
||||
"render.image_completed": "이미지 내보내기 준비됨",
|
||||
"render.image_failed": "렌더링 실패",
|
||||
"render.image_unknown": "렌더 상태를 확인할 수 없습니다",
|
||||
"render.image_reduce": "더 작은 이미지나 더 단순한 장면을 사용해 보세요.",
|
||||
"render.image_cancel": "렌더링 취소",
|
||||
"render.image_stale": "이 이미지는 후속 변경 전에 캡처한 장면을 사용합니다.",
|
||||
"render.image_start": "렌더링 시작",
|
||||
"render.image_overwrite": "기존 파일 바꾸기",
|
||||
"render.image_export": "PNG 내보내기",
|
||||
"cmd.render_preflight": "렌더 요청 확인",
|
||||
"cmd.render_start": "이미지 렌더링",
|
||||
"cmd.render_status": "렌더링 진행 상황",
|
||||
"cmd.render_cancel": "렌더링 취소",
|
||||
"cmd.render_export": "PNG 내보내기",
|
||||
"toolbar.image": "이미지",
|
||||
"appearance.surface_response": "표면 반응",
|
||||
"appearance.pbr_opaque": "불투명 PBR",
|
||||
"appearance.metallic": "금속성",
|
||||
"appearance.roughness": "거칠기",
|
||||
"appearance.dielectric_ior": "유전체 굴절률",
|
||||
"appearance.pbr_help": "이 표면은 렌더링 작업 공간이나 제품 PNG에서 확인하세요. 굴절과 투과는 지원하지 않습니다.",
|
||||
"render.opaque_preview_help": "불투명 PBR 및 단색 표시, 블렌딩 투명도를 지원합니다. 굴절, HDRI 및 최종 렌더링은 아직 지원하지 않습니다.",
|
||||
"render.opaque_image_summary": "미리보기 PNG · 불투명 PBR · 블렌딩 투명도",
|
||||
"UNTRIM": "트림 해제",
|
||||
"Face": "면",
|
||||
"Holes": "구멍",
|
||||
"Holes (edges on them)": "구멍 (구멍의 모서리)",
|
||||
"In place (surfaces)": "제자리에서 (서피스)",
|
||||
"None": "없음",
|
||||
"Picked": "선택한 항목",
|
||||
"Restore outer boundary": "외부 경계 복원",
|
||||
"RULED SURFACE": "룰드 서피스",
|
||||
"Mitre": "연귀",
|
||||
"Reference face": "참조 면",
|
||||
"Round": "둥글게",
|
||||
"Type": "유형"
|
||||
}
|
||||
+110
-1
@@ -77,6 +77,9 @@
|
||||
"cmd.doc_recovery_list": "Восстановимые дизайны",
|
||||
"cmd.document_inspect": "Инспекция дизайна",
|
||||
"cmd.document_units": "Изменить единицы дизайна",
|
||||
"cmd.document_metadata": "Свойства дизайна",
|
||||
"cmd.document_metadata_set": "Изменить свойства дизайна",
|
||||
"cmd.document_metadata_remove": "Удалить свойство дизайна",
|
||||
"cmd.edit_checkpoint": "Контрольная точка",
|
||||
"cmd.edit_restore_checkpoint": "Вернуть контрольную точку",
|
||||
"cmd.engine_commands": "Список команд",
|
||||
@@ -90,6 +93,7 @@
|
||||
"cmd.file_insert_mesh": "Вставить меш",
|
||||
"cmd.file_insert_step": "Вставить STEP",
|
||||
"cmd.file_save_mesh": "Сохранить как меш",
|
||||
"cmd.file_slicers": "Установленные слайсеры",
|
||||
"cmd.inspect_accessibility": "Анализ доступности",
|
||||
"cmd.inspect_center_of_mass": "Центр масс",
|
||||
"cmd.inspect_curvature_comb": "Анализ кривизны (гребёнка)",
|
||||
@@ -342,6 +346,21 @@
|
||||
"ui.coincident": "Совпадение",
|
||||
"ui.collapse": "Свернуть",
|
||||
"ui.collinear": "Коллинеарность",
|
||||
"ui.colour_vision": "Цветовое зрение",
|
||||
"ui.colour_vision_deutan": "Дейтан",
|
||||
"ui.colour_vision_deutan_tip": "При слабом или отсутствующем восприятии зелёного",
|
||||
"ui.colour_vision_failed": "Ошибка",
|
||||
"ui.colour_vision_fixed": "Фиксировано",
|
||||
"ui.colour_vision_mode": "Режим",
|
||||
"ui.colour_vision_note": "Меняет цвета, которые несут смысл: статус эскиза, оси, гибы, анализы и значки. Если вы пользуетесь цветовыми фильтрами Windows, оставьте «Выключено».",
|
||||
"ui.colour_vision_off": "Выключено",
|
||||
"ui.colour_vision_off_tip": "Собственные цвета темы",
|
||||
"ui.colour_vision_projected": "Проекция",
|
||||
"ui.colour_vision_protan": "Протан",
|
||||
"ui.colour_vision_protan_tip": "При слабом или отсутствующем восприятии красного",
|
||||
"ui.colour_vision_sample": "Пример",
|
||||
"ui.colour_vision_tritan": "Тритан",
|
||||
"ui.colour_vision_tritan_tip": "При слабом или отсутствующем восприятии синего",
|
||||
"ui.combine": "Объединение",
|
||||
"ui.coming_later": "появится позже",
|
||||
"ui.concentric": "Концентричность",
|
||||
@@ -567,6 +586,24 @@
|
||||
"ui.revolve": "Вращение",
|
||||
"ui.right": "Справа",
|
||||
"ui.save": "Сохранить",
|
||||
"ui.heading_save_mesh": "СОХРАНИТЬ КАК МЕШ",
|
||||
"ui.format": "Формат",
|
||||
"ui.mesh_format_stl": "STL",
|
||||
"ui.mesh_format_3mf": "3MF",
|
||||
"ui.mesh_format_3mf_tagged": "3MF с метками граней",
|
||||
"ui.send_to_slicer": "Отправить в слайсер",
|
||||
"ui.slicer": "Слайсер",
|
||||
"ui.custom_program": "Другая…",
|
||||
"ui.no_slicer_found": "Слайсер не найден",
|
||||
"ui.send": "Отправить",
|
||||
"ui.custom_program_tip": "Выберите программу-слайсер для добавления в список",
|
||||
"ui.slicer_delete_tip": "{program}\nНажмите Delete, чтобы убрать из списка",
|
||||
"ui.save_mesh_pick_bodies": "Выберите тела для сохранения",
|
||||
"ui.save_mesh_no_slicer": "Слайсер не найден: выберите «Другая…» и укажите программу или отключите «Отправить в слайсер»",
|
||||
"ui.not_a_program": "{path} — не программа, которую можно запустить",
|
||||
"ui.sent_to_slicer": "Отправлено в {slicer}",
|
||||
"ui.saved_to": "Сохранено: {path}",
|
||||
"ui.save_mesh_temp_failed": "Не удалось создать {folder}: {error}",
|
||||
"ui.save_as": "Сохранить как",
|
||||
"ui.save_as_ellipsis": "Сохранить как...",
|
||||
"ui.scale": "Масштаб",
|
||||
@@ -721,6 +758,8 @@
|
||||
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Файлы многих CAD-программ ориентированы Y вверх, поэтому здесь они открываются лёжа на спине (Z вверх).",
|
||||
"Filter": "Фильтр",
|
||||
"Find in Browser": "Найти в браузере",
|
||||
"Copy Error Message": "Скопировать сообщение об ошибке",
|
||||
"Copy Warning Message": "Скопировать предупреждение",
|
||||
"Flat size": "Размер развёртки",
|
||||
"Follow camera (Look At sketches)": "Следовать за камерой (эскизы «Взгляд на»)",
|
||||
"Font": "Шрифт",
|
||||
@@ -909,6 +948,17 @@
|
||||
"ui.pinToShortcuts": "Закрепить в сочетаниях клавиш",
|
||||
"ui.pinToToolbar": "Закрепить на панели",
|
||||
"ui.properties": "Свойства",
|
||||
"ui.design_properties.project": "Проект",
|
||||
"ui.design_properties.part_number": "Номер детали",
|
||||
"ui.design_properties.revision": "Редакция",
|
||||
"ui.design_properties.author": "Автор",
|
||||
"ui.design_properties.organization": "Организация",
|
||||
"ui.design_properties.description": "Описание",
|
||||
"ui.design_properties.license": "Лицензия",
|
||||
"ui.design_properties.not_text": "Сохранено не как текст; ввод здесь заменит значение.",
|
||||
"ui.design_properties.exports": "Экспорт в STEP, IGES и 3MF записывает эти свойства, если в формате для них есть поле.",
|
||||
"ui.design_properties.others": "Данные других программ",
|
||||
"ui.design_properties.others_note": "Хранятся в дизайне без изменений и никогда не экспортируются.",
|
||||
"ui.removeSection": "Убрать сечение",
|
||||
"ui.removeFromToolbar": "Убрать с панели",
|
||||
"ui.repeat": "Повторить",
|
||||
@@ -932,6 +982,9 @@
|
||||
"ui.updateToCurrentView": "Обновить до текущего вида",
|
||||
"ui.zoom": "Масштаб",
|
||||
"ui.command_done": "{command} — готово",
|
||||
"drive.stopped_by_loop": "Остановлено на {value}: контур через {joint} дальше не замыкается",
|
||||
"drive.stopped_by_contact": "Остановлено на {value}: {a} прошёл бы сквозь {b}",
|
||||
"drive.unconnected": "Ничего не движется: {part} не соединён ни с чем закреплённым",
|
||||
"timeline.group": "Новая папка операций",
|
||||
"timeline.ungroup": "Убрать папки операций",
|
||||
"timeline.rename_group": "Переименовать папку операций",
|
||||
@@ -957,5 +1010,61 @@
|
||||
"Line Spacing": "Межстрочный интервал",
|
||||
"Path": "Траектория",
|
||||
"sketch_text.on_path": "Текст следует по траектории: кривая задаёт его положение",
|
||||
"cmd.material_properties_validate": "Проверить данные физического материала"
|
||||
"cmd.material_properties_validate": "Проверить данные физического материала",
|
||||
"cmd.render_settings_get": "Настройки рендеринга",
|
||||
"cmd.render_settings_set": "Сохранить настройки рендеринга",
|
||||
"cmd.render_scene": "Просмотреть сцену рендеринга",
|
||||
"cmd.render_settings_apply": "Применить камеру рендеринга",
|
||||
"toolbar.render": "РЕНДЕРИНГ",
|
||||
"toolbar.render_scene": "СЦЕНА",
|
||||
"render.preview": "Встроенный предпросмотр",
|
||||
"render.preview_help": "Растровое затенение и смешанная прозрачность. Преломление, HDRI и финальный рендеринг пока недоступны.",
|
||||
"render.setup_name": "Имя набора настроек",
|
||||
"render.background": "Фон сцены",
|
||||
"render.perspective": "Перспективная камера",
|
||||
"render.fit": "Вписать модель",
|
||||
"render.restore_camera": "Восстановить сохранённую камеру",
|
||||
"render.save_setup": "Сохранить набор",
|
||||
"render.unsaved": "Несохранённые изменения предпросмотра",
|
||||
"render.reload_settings": "Загрузить сохранённые настройки",
|
||||
"render.unavailable_preview": "Этот набор запрашивает недоступный профиль предпросмотра.",
|
||||
"render.use_preview": "Использовать встроенный предпросмотр",
|
||||
"render.navigation_help": "Перетаскивайте для вращения, используйте среднюю кнопку для перемещения и колесо для масштаба. Сохранение набора записывает текущую камеру.",
|
||||
"render.return_design": "Вернуться к проектированию",
|
||||
"render.image_title": "Рендеринг изображения",
|
||||
"render.image_help": "Предпросмотр на CPU · PNG · приближённая прозрачность",
|
||||
"render.image_width": "Ширина",
|
||||
"render.image_height": "Высота",
|
||||
"render.image_transparent": "Прозрачный фон",
|
||||
"render.image_check": "Проверить запрос рендеринга",
|
||||
"render.image_supported": "Запрос поддерживается; при запуске очередь и пул памяти будут проверены снова.",
|
||||
"render.image_queued": "Ожидание рендеринга",
|
||||
"render.image_preparing": "Подготовка изображения",
|
||||
"render.image_running": "Рендеринг изображения",
|
||||
"render.image_encoding": "Кодирование PNG",
|
||||
"render.image_cancelling": "Отмена",
|
||||
"render.image_cancelled": "Рендеринг отменён",
|
||||
"render.image_completed": "Изображение готово к экспорту",
|
||||
"render.image_failed": "Ошибка рендеринга",
|
||||
"render.image_unknown": "Состояние рендеринга недоступно",
|
||||
"render.image_reduce": "Попробуйте уменьшить изображение или упростить сцену.",
|
||||
"render.image_cancel": "Отменить рендеринг",
|
||||
"render.image_stale": "Используется сцена, сохранённая до последующих изменений.",
|
||||
"render.image_start": "Начать рендеринг",
|
||||
"render.image_overwrite": "Заменить существующий файл",
|
||||
"render.image_export": "Экспорт PNG",
|
||||
"cmd.render_preflight": "Проверить запрос рендеринга",
|
||||
"cmd.render_start": "Рендеринг изображения",
|
||||
"cmd.render_status": "Ход рендеринга",
|
||||
"cmd.render_cancel": "Отменить рендеринг",
|
||||
"cmd.render_export": "Экспортировать изображение рендеринга",
|
||||
"toolbar.image": "ИЗОБРАЖЕНИЕ",
|
||||
"appearance.surface_response": "Поведение поверхности",
|
||||
"appearance.pbr_opaque": "Непрозрачный PBR",
|
||||
"appearance.metallic": "Металличность",
|
||||
"appearance.roughness": "Шероховатость",
|
||||
"appearance.dielectric_ior": "Показатель преломления диэлектрика",
|
||||
"appearance.pbr_help": "Просмотр поверхности во вкладке Рендер или на PNG изделия. Без преломления и пропускания света.",
|
||||
"render.opaque_preview_help": "Непрозрачный PBR и простые цвета; прозрачность через смешивание. Преломление, HDRI и финальный рендер пока недоступны.",
|
||||
"render.opaque_image_summary": "Предпросмотр PNG · непрозрачный PBR · прозрачность через смешивание"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+143
-1
@@ -4,6 +4,12 @@
|
||||
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 点击下方按钮打开 GitHub 草稿。标题和报告将自动填入。",
|
||||
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 附上截图:复制并粘贴,或保存为 PNG 后上传。",
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. 在 GitHub 上点击 Submit new issue 发送报告。",
|
||||
"nav.touchpad.label": "触控板",
|
||||
"nav.touchpad.help": "按住 Shift 并移动指针可平移;按住 Alt/Option 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。",
|
||||
"nav.touchpad.orbit": "旋转(按住 Alt/Option 并移动,无需鼠标按钮)",
|
||||
"nav.touchpad.pan": "平移(按住 Shift 并移动,无需鼠标按钮)",
|
||||
"nav.touchpad.zoom": "缩放(垂直滚动或双指捏合)",
|
||||
"nav.touchpad.scroll": "触控板使用垂直滚动进行缩放。双指平移设置将保留供其他预设使用。",
|
||||
"browser.body_name": "实体{number}",
|
||||
"browser.component_name": "组件{number}",
|
||||
"browser.parameters": "参数 ({count})",
|
||||
@@ -82,6 +88,9 @@
|
||||
"cmd.doc_recovery_list": "可恢复的设计",
|
||||
"cmd.document_inspect": "检查设计",
|
||||
"cmd.document_units": "更改设计单位",
|
||||
"cmd.document_metadata": "设计属性",
|
||||
"cmd.document_metadata_set": "更改设计属性",
|
||||
"cmd.document_metadata_remove": "删除设计属性",
|
||||
"cmd.edit_checkpoint": "检查点",
|
||||
"cmd.edit_restore_checkpoint": "恢复检查点",
|
||||
"cmd.engine_commands": "列出命令",
|
||||
@@ -95,6 +104,7 @@
|
||||
"cmd.file_insert_mesh": "插入网格",
|
||||
"cmd.file_insert_step": "插入 STEP",
|
||||
"cmd.file_save_mesh": "另存为网格",
|
||||
"cmd.file_slicers": "已安装的切片软件",
|
||||
"cmd.inspect_accessibility": "可达性分析",
|
||||
"cmd.inspect_center_of_mass": "质心",
|
||||
"cmd.inspect_curvature_comb": "曲率梳分析",
|
||||
@@ -283,6 +293,7 @@
|
||||
"cmd.view_list": "列出命名视图",
|
||||
"cmd.view_rename": "重命名命名视图",
|
||||
"cmd.view_save": "新建命名视图",
|
||||
"dialog.computing": "正在计算… {seconds} 秒",
|
||||
"dialog.custom_axis": "↖ 自定义轴",
|
||||
"dialog.edit_title": "编辑 {title}",
|
||||
"dialog.select": "↖ 选择",
|
||||
@@ -316,6 +327,16 @@
|
||||
"ui.animation": "动画",
|
||||
"ui.animation_speed": "动画速度",
|
||||
"ui.anti_aliasing": "抗锯齿",
|
||||
"ui.supersampling": "超级采样 (SSAA)",
|
||||
"ui.supersampling_off": "关 (1×)",
|
||||
"ui.supersampling_adaptive": "仅在视图静止时",
|
||||
"settings.supersampling_note": "以更高分辨率绘制视图再缩小:在低于 4K 的屏幕上边、线和网格更平滑。会消耗 GPU 时间:1.5× 时约 2.25 倍像素,2× 时 4 倍。",
|
||||
"ui.display_quality": "显示质量",
|
||||
"ui.display_quality_coarse": "粗糙",
|
||||
"ui.display_quality_normal": "标准",
|
||||
"ui.display_quality_fine": "精细",
|
||||
"ui.display_quality_ultra": "极高",
|
||||
"settings.display_quality_note": "级别越精细,曲面和边显示越平滑,但网格生成耗时更长。只影响视口,不影响设计或导出。",
|
||||
"ui.apply": "应用",
|
||||
"ui.arc": "圆弧",
|
||||
"ui.assemble": "装配",
|
||||
@@ -349,6 +370,21 @@
|
||||
"ui.coincident": "重合",
|
||||
"ui.collapse": "折叠",
|
||||
"ui.collinear": "共线",
|
||||
"ui.colour_vision": "色觉",
|
||||
"ui.colour_vision_deutan": "绿色觉异常(Deutan)",
|
||||
"ui.colour_vision_deutan_tip": "绿色感知较弱或缺失时使用",
|
||||
"ui.colour_vision_failed": "失败",
|
||||
"ui.colour_vision_fixed": "固定",
|
||||
"ui.colour_vision_mode": "模式",
|
||||
"ui.colour_vision_note": "更改表示含义的颜色:草图状态、轴、折弯、分析和图标。如果使用 Windows 颜色滤镜,请保持“关闭”。",
|
||||
"ui.colour_vision_off": "关闭",
|
||||
"ui.colour_vision_off_tip": "主题本身的颜色",
|
||||
"ui.colour_vision_projected": "投影",
|
||||
"ui.colour_vision_protan": "红色觉异常(Protan)",
|
||||
"ui.colour_vision_protan_tip": "红色感知较弱或缺失时使用",
|
||||
"ui.colour_vision_sample": "示例",
|
||||
"ui.colour_vision_tritan": "蓝色觉异常(Tritan)",
|
||||
"ui.colour_vision_tritan_tip": "蓝色感知较弱或缺失时使用",
|
||||
"ui.combine": "组合",
|
||||
"ui.coming_later": "即将推出",
|
||||
"ui.concentric": "同心",
|
||||
@@ -559,6 +595,10 @@
|
||||
"ui.quit": "退出",
|
||||
"ui.radial_context_menu": "环形上下文菜单",
|
||||
"ui.radius": "半径",
|
||||
"ui.pull_direction": "拔模方向",
|
||||
"ui.tool_direction": "刀具方向",
|
||||
"ui.reachable": "可到达",
|
||||
"ui.blocked": "被遮挡",
|
||||
"ui.radius_type": "半径类型",
|
||||
"ui.rectangle": "矩形",
|
||||
"ui.rectangular_pattern": "矩形阵列",
|
||||
@@ -573,6 +613,24 @@
|
||||
"ui.revolve": "旋转",
|
||||
"ui.right": "右视",
|
||||
"ui.save": "保存",
|
||||
"ui.heading_save_mesh": "另存为网格",
|
||||
"ui.format": "格式",
|
||||
"ui.mesh_format_stl": "STL",
|
||||
"ui.mesh_format_3mf": "3MF",
|
||||
"ui.mesh_format_3mf_tagged": "带面标签的 3MF",
|
||||
"ui.send_to_slicer": "发送到切片软件",
|
||||
"ui.slicer": "切片软件",
|
||||
"ui.custom_program": "自定义…",
|
||||
"ui.no_slicer_found": "未找到切片软件",
|
||||
"ui.send": "发送",
|
||||
"ui.custom_program_tip": "选择要添加到列表的切片软件程序",
|
||||
"ui.slicer_delete_tip": "{program}\n按 Delete 键从列表中移除",
|
||||
"ui.save_mesh_pick_bodies": "请选择要保存的实体",
|
||||
"ui.save_mesh_no_slicer": "未找到切片软件:请选择“自定义…”并指定切片软件程序,或关闭“发送到切片软件”",
|
||||
"ui.not_a_program": "{path} 不是可以启动的程序",
|
||||
"ui.sent_to_slicer": "已发送到 {slicer}",
|
||||
"ui.saved_to": "已保存:{path}",
|
||||
"ui.save_mesh_temp_failed": "无法创建 {folder}:{error}",
|
||||
"ui.save_as": "另存为",
|
||||
"ui.save_as_ellipsis": "另存为...",
|
||||
"ui.scale": "缩放",
|
||||
@@ -727,6 +785,8 @@
|
||||
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "许多 CAD 程序的文件以 Y 为上轴,因此在此处打开时会平躺(Z 朝上)。",
|
||||
"Filter": "筛选",
|
||||
"Find in Browser": "在浏览器中查找",
|
||||
"Copy Error Message": "复制错误消息",
|
||||
"Copy Warning Message": "复制警告消息",
|
||||
"Flat size": "平板尺寸",
|
||||
"Follow camera (Look At sketches)": "跟随相机(注视草图)",
|
||||
"Font": "字体",
|
||||
@@ -915,6 +975,17 @@
|
||||
"ui.pinToShortcuts": "固定到快捷方式",
|
||||
"ui.pinToToolbar": "固定到工具栏",
|
||||
"ui.properties": "属性",
|
||||
"ui.design_properties.project": "项目",
|
||||
"ui.design_properties.part_number": "零件号",
|
||||
"ui.design_properties.revision": "版本",
|
||||
"ui.design_properties.author": "作者",
|
||||
"ui.design_properties.organization": "组织",
|
||||
"ui.design_properties.description": "描述",
|
||||
"ui.design_properties.license": "许可证",
|
||||
"ui.design_properties.not_text": "已保存为非文本值;在此输入将替换它。",
|
||||
"ui.design_properties.exports": "导出 STEP、IGES 和 3MF 时,若格式有对应字段,会写入这些属性。",
|
||||
"ui.design_properties.others": "其他工具的数据",
|
||||
"ui.design_properties.others_note": "按原样保存在设计中,从不导出。",
|
||||
"ui.removeSection": "移除剖面",
|
||||
"ui.removeFromToolbar": "从工具栏移除",
|
||||
"ui.repeat": "重复",
|
||||
@@ -938,6 +1009,9 @@
|
||||
"ui.updateToCurrentView": "更新为当前视图",
|
||||
"ui.zoom": "缩放",
|
||||
"ui.command_done": "{command} 完成",
|
||||
"drive.stopped_by_loop": "停在 {value}:经过 {joint} 的环路无法再闭合",
|
||||
"drive.stopped_by_contact": "停在 {value}:{a} 会穿过 {b}",
|
||||
"drive.unconnected": "没有任何移动:{part} 未连接到任何固定的零部件",
|
||||
"timeline.group": "新建特征文件夹",
|
||||
"timeline.ungroup": "移除特征文件夹",
|
||||
"timeline.rename_group": "重命名特征文件夹",
|
||||
@@ -1011,5 +1085,73 @@
|
||||
"update.restart_later": "稍后",
|
||||
"update.installer_started": "SolidCraft 关闭后将启动安装程序。",
|
||||
"update.dmg_opened": "磁盘映像已校验并打开:将 SolidCraft 拖到“应用程序”替换旧版本,然后重启 SolidCraft。",
|
||||
"update.install_failed": "更新未安装:{error}。未做任何更改;可用“下载”获取安装包。"
|
||||
"update.install_failed": "更新未安装:{error}。未做任何更改;可用“下载”获取安装包。",
|
||||
"cmd.render_settings_get": "渲染设置",
|
||||
"cmd.render_settings_set": "保存渲染设置",
|
||||
"cmd.render_scene": "检查渲染场景",
|
||||
"cmd.render_settings_apply": "应用渲染相机",
|
||||
"toolbar.render": "渲染",
|
||||
"toolbar.render_scene": "场景",
|
||||
"render.preview": "集成预览",
|
||||
"render.preview_help": "光栅着色和混合透明度。暂不支持折射、HDRI 和最终渲染。",
|
||||
"render.setup_name": "设置名称",
|
||||
"render.background": "场景背景",
|
||||
"render.perspective": "透视相机",
|
||||
"render.fit": "适配产品视图",
|
||||
"render.restore_camera": "恢复已保存相机",
|
||||
"render.save_setup": "保存设置",
|
||||
"render.unsaved": "未保存的预览更改",
|
||||
"render.reload_settings": "重新加载已保存设置",
|
||||
"render.unavailable_preview": "此设置请求了不可用的预览配置。",
|
||||
"render.use_preview": "使用集成预览",
|
||||
"render.navigation_help": "拖动以旋转,按住中键拖动以平移,滚动以缩放。保存设置会记录当前相机。",
|
||||
"render.return_design": "返回设计",
|
||||
"render.image_title": "渲染图像",
|
||||
"render.image_help": "CPU 预览图像 · PNG · 近似透明度",
|
||||
"render.image_width": "宽度",
|
||||
"render.image_height": "高度",
|
||||
"render.image_transparent": "透明背景",
|
||||
"render.image_check": "检查渲染请求",
|
||||
"render.image_supported": "支持此渲染请求;开始时将再次检查队列和内存池。",
|
||||
"render.image_queued": "等待渲染",
|
||||
"render.image_preparing": "正在准备图像",
|
||||
"render.image_running": "正在渲染图像",
|
||||
"render.image_encoding": "正在编码 PNG",
|
||||
"render.image_cancelling": "正在取消",
|
||||
"render.image_cancelled": "渲染已取消",
|
||||
"render.image_completed": "图像可导出",
|
||||
"render.image_failed": "渲染失败",
|
||||
"render.image_unknown": "渲染状态不可用",
|
||||
"render.image_reduce": "请尝试更小的图像或更简单的场景。",
|
||||
"render.image_cancel": "取消渲染",
|
||||
"render.image_stale": "此图像使用后续更改之前捕获的场景。",
|
||||
"render.image_start": "开始渲染",
|
||||
"render.image_overwrite": "替换现有文件",
|
||||
"render.image_export": "导出 PNG",
|
||||
"cmd.render_preflight": "检查渲染请求",
|
||||
"cmd.render_start": "渲染图像",
|
||||
"cmd.render_status": "渲染进度",
|
||||
"cmd.render_cancel": "取消渲染",
|
||||
"cmd.render_export": "导出 PNG",
|
||||
"toolbar.image": "图像",
|
||||
"appearance.surface_response": "表面响应",
|
||||
"appearance.pbr_opaque": "不透明 PBR",
|
||||
"appearance.metallic": "金属度",
|
||||
"appearance.roughness": "粗糙度",
|
||||
"appearance.dielectric_ior": "电介质折射率",
|
||||
"appearance.pbr_help": "在渲染工作区或产品 PNG 中查看此表面。不支持折射或透射。",
|
||||
"render.opaque_preview_help": "不透明 PBR 和纯色;透明效果使用混合。不支持折射、HDRI 和最终渲染。",
|
||||
"render.opaque_image_summary": "预览 PNG · 不透明 PBR · 混合透明",
|
||||
"UNTRIM": "取消修剪",
|
||||
"Face": "面",
|
||||
"Holes": "孔",
|
||||
"Holes (edges on them)": "孔(孔上的边)",
|
||||
"In place (surfaces)": "原位(曲面)",
|
||||
"None": "无",
|
||||
"Picked": "所选",
|
||||
"Restore outer boundary": "恢复外边界",
|
||||
"RULED SURFACE": "直纹曲面",
|
||||
"Mitre": "斜接",
|
||||
"Reference face": "参考面",
|
||||
"Round": "圆角"
|
||||
}
|
||||
@@ -10,6 +10,10 @@ struct U {
|
||||
acc_u: vec4<f32>, // accessibility map axes (xyz, offset)
|
||||
acc_v: vec4<f32>,
|
||||
eye: vec4<f32>, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
|
||||
// analysis colours (`theme::AnalysisColors`): draft positive, between, negative; reachable,
|
||||
// blocked; curvature flat, middle, tight
|
||||
ana_c: array<vec4<f32>, 8>,
|
||||
ana_f: vec4<f32>, // x = 1: stripe the negative draft and blocked faces
|
||||
};
|
||||
|
||||
@group(1) @binding(2) var acc_t: texture_2d<f32>;
|
||||
@@ -150,8 +154,8 @@ fn analysis(i: TOut, k: f32) -> vec4<f32> {
|
||||
// Slack of a pixel and a half, more on slopes (the map holds the pixel's highest point).
|
||||
let slack = u.ana.y * (1.0 + sqrt(max(1.0 - cn * cn, 0.0)) / max(cn, 0.05));
|
||||
let clear = dot(i.wp, d) >= top - slack;
|
||||
var c = vec3<f32>(0.85, 0.22, 0.20);
|
||||
if (facing && clear) { c = vec3<f32>(0.20, 0.75, 0.30); }
|
||||
var c = u.ana_c[4].rgb;
|
||||
if (facing && clear) { c = u.ana_c[3].rgb; } else { c = stripe(i, c); }
|
||||
return out_color(vec4<f32>(c * lit, 1.0));
|
||||
}
|
||||
if (u.ana.x > 3.5) {
|
||||
@@ -171,24 +175,37 @@ fn analysis(i: TOut, k: f32) -> vec4<f32> {
|
||||
}
|
||||
if (u.ana.x < 2.5) {
|
||||
let a = degrees(asin(clamp(dot(normalize(i.n), normalize(u.pull.xyz)), -1.0, 1.0)));
|
||||
var c = vec3<f32>(0.95, 0.80, 0.15);
|
||||
if (a >= u.ana.y) { c = vec3<f32>(0.20, 0.75, 0.30); }
|
||||
if (a <= -u.ana.y) { c = vec3<f32>(0.85, 0.22, 0.20); }
|
||||
var c = u.ana_c[1].rgb;
|
||||
if (a >= u.ana.y) { c = u.ana_c[0].rgb; }
|
||||
if (a <= -u.ana.y) { c = stripe(i, u.ana_c[2].rgb); }
|
||||
return out_color(vec4<f32>(c * lit, 1.0));
|
||||
}
|
||||
// Curvature from how fast the normal turns across the pixel.
|
||||
let t = clamp(k * u.ana.y, 0.0, 1.0);
|
||||
let c = select(mix(vec3<f32>(0.10, 0.80, 0.25), vec3<f32>(0.90, 0.15, 0.10), t * 2.0 - 1.0),
|
||||
mix(vec3<f32>(0.15, 0.35, 0.95), vec3<f32>(0.10, 0.80, 0.25), t * 2.0), t < 0.5);
|
||||
let c = select(mix(u.ana_c[6].rgb, u.ana_c[7].rgb, t * 2.0 - 1.0), mix(u.ana_c[5].rgb, u.ana_c[6].rgb, t * 2.0), t < 0.5);
|
||||
return out_color(vec4<f32>(c * lit, 1.0));
|
||||
}
|
||||
|
||||
/// A "bad" analysis face (negative draft, blocked): with the stripes on (colour vision modes), the
|
||||
/// section cap's 45 degree hatch over it, so colour is not the only cue.
|
||||
fn stripe(i: TOut, c: vec3<f32>) -> vec3<f32> {
|
||||
if (u.ana_f.x < 0.5) { return c; }
|
||||
let h = fract((i.pos.x + i.pos.y) / 9.0);
|
||||
return select(c, c * 0.6, h < 0.22);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_tri(i: TOut) -> @location(0) vec4<f32> {
|
||||
if (clipped(i.wp)) { discard; }
|
||||
return shade(i);
|
||||
}
|
||||
|
||||
/// Reference planes: shaded like `fs_tri`, but never cut by the section plane.
|
||||
@fragment
|
||||
fn fs_tri_all(i: TOut) -> @location(0) vec4<f32> {
|
||||
return shade(i);
|
||||
}
|
||||
|
||||
fn shade(i: TOut) -> vec4<f32> {
|
||||
// A zero normal marks flat colour (selection fills, translucent planes).
|
||||
if (length(i.n) < 0.5) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Original light and dark palette values. Compiled by the UI theme boundary.
|
||||
use crate::theme::Tokens;
|
||||
use crate::theme::{AnalysisColors, IconColors, Tokens};
|
||||
use egui::Color32;
|
||||
impl Tokens {
|
||||
pub const fn light() -> Tokens {
|
||||
@@ -76,13 +76,54 @@ impl Tokens {
|
||||
rubber_band: Color32::from_rgb(30, 120, 230),
|
||||
app_bar_text: Color32::from_rgb(230, 234, 242),
|
||||
app_bar_hover: Color32::from_rgba_unmultiplied_const(255, 255, 255, 28),
|
||||
app_bar_tab_edge: Color32::from_rgba_unmultiplied_const(255, 255, 255, 40),
|
||||
timeline_item: Color32::WHITE,
|
||||
timeline_item_error: Color32::from_rgb(250, 220, 220),
|
||||
timeline_marker: Color32::from_rgb(70, 76, 88),
|
||||
field: Color32::WHITE,
|
||||
box_window: Color32::from_rgb(38, 120, 218),
|
||||
box_crossing: Color32::from_rgb(40, 160, 80),
|
||||
// Equal to `view::colors` (the engine's tags), so mapping a tag to its token
|
||||
// changes nothing.
|
||||
sketch_projected: Color32::from_rgb(150, 60, 190),
|
||||
sketch_lost: Color32::from_rgb(200, 60, 90),
|
||||
sketch_construction: Color32::from_rgb(230, 130, 40),
|
||||
sketch_centerline: Color32::from_rgb(200, 110, 60),
|
||||
triad: [Color32::from_rgb(220, 60, 60), Color32::from_rgb(60, 170, 60), Color32::from_rgb(60, 100, 230)],
|
||||
gizmo: [Color32::from_rgb(225, 70, 60), Color32::from_rgb(80, 190, 80), Color32::from_rgb(70, 130, 240)],
|
||||
bend_up: Color32::from_rgb(30, 140, 50),
|
||||
bend_down: Color32::from_rgb(200, 90, 20),
|
||||
interference: Color32::from_rgb(235, 45, 40),
|
||||
analysis: AnalysisColors {
|
||||
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
|
||||
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
|
||||
curvature: [[0.15, 0.35, 0.95], [0.10, 0.80, 0.25], [0.90, 0.15, 0.10]],
|
||||
hatch: false,
|
||||
},
|
||||
icons: IconColors {
|
||||
sheet: Color32::from_rgb(170, 200, 240),
|
||||
sheet_2: Color32::from_rgb(150, 185, 235),
|
||||
sheet_light: Color32::from_rgb(214, 229, 248),
|
||||
sheet_line: Color32::from_rgb(127, 166, 218),
|
||||
highlight: Color32::WHITE,
|
||||
glint: Color32::from_rgba_premultiplied(160, 160, 160, 160),
|
||||
folder: Color32::from_rgb(236, 200, 110),
|
||||
plane: Color32::from_rgb(244, 214, 150),
|
||||
fold: Color32::from_rgb(130, 140, 160),
|
||||
stripe: Color32::from_gray(30),
|
||||
sky: Color32::from_rgb(190, 205, 230),
|
||||
floor: Color32::from_rgb(60, 62, 70),
|
||||
paint: [Color32::from_rgb(220, 70, 60), Color32::from_rgb(70, 150, 230), Color32::from_rgb(240, 200, 60)],
|
||||
axes: [Color32::from_rgb(210, 60, 60), Color32::from_rgb(60, 160, 60), Color32::from_rgb(60, 100, 220)],
|
||||
access: [Color32::from_rgb(70, 180, 90), Color32::from_rgb(220, 70, 60)],
|
||||
draft: [Color32::from_rgb(70, 180, 90), Color32::from_rgb(235, 200, 50)],
|
||||
curvature: [Color32::from_rgb(60, 110, 230), Color32::from_rgb(70, 190, 90), Color32::from_rgb(225, 60, 45)],
|
||||
interference: Color32::from_rgb(225, 70, 60),
|
||||
warning: Color32::from_rgb(250, 200, 60),
|
||||
agent: Color32::from_rgb(168, 85, 247),
|
||||
marker_halo: Color32::from_black_alpha(150),
|
||||
},
|
||||
app_bar_icon: [Color32::from_rgb(225, 230, 238), Color32::from_rgb(120, 140, 170), Color32::from_rgb(120, 190, 255)],
|
||||
logo: [Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197), Color32::WHITE],
|
||||
}
|
||||
}
|
||||
pub const fn dark() -> Tokens {
|
||||
@@ -135,6 +176,60 @@ impl Tokens {
|
||||
t.field = Color32::from_rgb(30, 34, 41);
|
||||
t.box_window = Color32::from_rgb(70, 150, 255);
|
||||
t.box_crossing = Color32::from_rgb(80, 200, 120);
|
||||
t.bend_up = Color32::from_rgb(110, 200, 120);
|
||||
t.bend_down = Color32::from_rgb(240, 150, 80);
|
||||
t
|
||||
}
|
||||
|
||||
/// Colour vision, red-green (Protan and Deutan share it, #778): values of the theme's colours
|
||||
/// that a weak red or green sense still tells apart. Each was measured against normal vision,
|
||||
/// protan and deutan, with two simulations of each loss (`colour_vision_tests`).
|
||||
pub(super) const fn red_green(self, dark: bool) -> Tokens {
|
||||
let mut t = self;
|
||||
let positive = [0.20, 0.45, 0.90];
|
||||
let negative = [0.85, 0.40, 0.05];
|
||||
t.triad[0] = Color32::from_rgb(200, 40, 40);
|
||||
t.triad[1] = Color32::from_rgb(120, 210, 80);
|
||||
t.gizmo[1] = Color32::from_rgb(140, 220, 90);
|
||||
t.preview_sketch = Color32::from_rgb(80, 185, 145);
|
||||
t.analysis.draft = [positive, [0.95, 0.90, 0.60], negative];
|
||||
t.analysis.access = [positive, negative];
|
||||
t.analysis.hatch = true;
|
||||
// The icons that show the same meanings follow; on the dark toolbar the blue is a little
|
||||
// stronger, to stay clear of the icon fill.
|
||||
let good = if dark { Color32::from_rgb(35, 115, 245) } else { Color32::from_rgb(51, 115, 230) };
|
||||
t.icons.axes[0] = t.triad[0];
|
||||
t.icons.axes[1] = t.triad[1];
|
||||
t.icons.access = [good, Color32::from_rgb(217, 102, 13)];
|
||||
t.icons.draft = [good, Color32::from_rgb(242, 230, 153)];
|
||||
if dark {
|
||||
t.bend_up = Color32::from_rgb(110, 165, 255);
|
||||
t.bend_down = Color32::from_rgb(240, 180, 70);
|
||||
t.sketch_accent = Color32::from_rgb(150, 225, 195);
|
||||
} else {
|
||||
t.sketch_projected = Color32::from_rgb(205, 120, 215);
|
||||
t.sketch_locked = Color32::from_rgb(60, 150, 120);
|
||||
t.timeline_item_error = Color32::from_rgb(232, 196, 170);
|
||||
t.bend_up = Color32::from_rgb(30, 100, 210);
|
||||
t.bend_down = Color32::from_rgb(200, 120, 0);
|
||||
t.sketch_accent = Color32::from_rgb(60, 150, 105);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Colour vision, blue-yellow (Tritan, #778), measured against normal vision and tritan.
|
||||
pub(super) const fn blue_yellow(self, dark: bool) -> Tokens {
|
||||
let mut t = self;
|
||||
t.analysis.hatch = true;
|
||||
t.preview_sketch = Color32::from_rgb(125, 180, 105);
|
||||
if dark {
|
||||
t.sketch_locked = Color32::from_rgb(140, 225, 110);
|
||||
t.box_crossing = Color32::from_rgb(240, 110, 170);
|
||||
t.glyph_text = Color32::from_rgb(240, 180, 165);
|
||||
} else {
|
||||
t.sketch_locked = Color32::from_rgb(20, 165, 70);
|
||||
t.box_crossing = Color32::from_rgb(200, 60, 120);
|
||||
}
|
||||
t
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "solidcraft-derive"
|
||||
description = "SolidCraft derive macros: the walk over a feature's geometry, from role attributes on its fields"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = { workspace = true }
|
||||
quote = { workspace = true }
|
||||
syn = { workspace = true }
|
||||
@@ -0,0 +1,294 @@
|
||||
//! `#[derive(Walk)]`: the walks over a feature (`crate::walk::Walk`), generated from the fields of
|
||||
//! a struct or an enum's variants. Each walk visits the fields in declaration order, and comes in
|
||||
//! `&` and `&mut`:
|
||||
//!
|
||||
//! - expressions (`walk_exprs`): each with its label, kind and role; the inputs' order names their
|
||||
//! parameters (`dN`);
|
||||
//! - references (`walk_refs`): sketches by id, features and bodies by name;
|
||||
//! - geometry (`walk_geometry`): points with their roles, planes, construction references,
|
||||
//! placement points and profile selections.
|
||||
//!
|
||||
//! What a field is, is written once, on the field, with `#[walk(...)]`:
|
||||
//!
|
||||
//! - no attribute: the field's own type walks itself (`impl Walk`): a plane, a nested struct, a
|
||||
//! list of them, or nothing for names, flags and numbers. A bare `Vec3` has no walk, so a point
|
||||
//! that does not say its role does not compile;
|
||||
//! - `length = label`, `angle = label`, `unitless = label`: an expression input (a `String`, an
|
||||
//! optional one, a list or an array of them, or `(f64, String)` pairs). The label is any
|
||||
//! expression: a `&'static str`, an array of them (one per element), or a closure
|
||||
//! `|i: usize, n: usize| -> &'static str`; it may read the other fields;
|
||||
//! - `sketch`, `feature`, `body`: a reference (an id, or a name, optional or a list);
|
||||
//! - `pick`, `pinned`, `pos`, `dir`: a point (`Vec3`, optional or a list) with that role;
|
||||
//! `pick_if = cond`: a pick when `cond` holds, else a position;
|
||||
//! - `placement`: a primitive's placement point (its X, Y and Z expressions are not inputs);
|
||||
//! - `profiles_of = sketch_field`, optionally with `when = cond`: a profile selection;
|
||||
//! - `after = field`: visit this field right after `field` instead of in declaration order (where
|
||||
//! the parameter names already given need it);
|
||||
//! - `picks_faces`, on a nested value: its positions pick faces here (a `FaceAt` that names the
|
||||
//! face it is on).
|
||||
//!
|
||||
//! Conditions and labels are worked out before the walk starts. The generated code names
|
||||
//! `crate::walk`, so the derive is for `solidcraft-doc`.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use proc_macro2::{Span, TokenStream as Tokens};
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{Data, DeriveInput, Expr, Fields, Ident, Member, parse_macro_input};
|
||||
|
||||
#[proc_macro_derive(Walk, attributes(walk))]
|
||||
pub fn derive_walk(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
match expand(&input) {
|
||||
Ok(t) => t.into(),
|
||||
Err(e) => e.to_compile_error().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A field's role in the geometry walk.
|
||||
enum Geo {
|
||||
Points(&'static str),
|
||||
PickIf(Expr),
|
||||
Placement,
|
||||
Profiles { sketch: Ident, when: Option<Expr> },
|
||||
}
|
||||
|
||||
/// What a field is, from its `#[walk(...)]` attributes.
|
||||
#[derive(Default)]
|
||||
struct Roles {
|
||||
geo: Option<Geo>,
|
||||
/// The kind (`Length`, `Angle`, `Unitless`) and the label.
|
||||
expr: Option<(&'static str, Expr)>,
|
||||
/// `Sketch`, `Feature` or `Body`.
|
||||
reference: Option<&'static str>,
|
||||
after: Option<Ident>,
|
||||
/// The positions inside this (nested) value pick faces here.
|
||||
picks_faces: bool,
|
||||
}
|
||||
|
||||
impl Roles {
|
||||
/// No role given: the field's type walks itself.
|
||||
fn nested(&self) -> bool {
|
||||
self.geo.is_none() && self.expr.is_none() && self.reference.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
fn roles(field: &syn::Field) -> syn::Result<Roles> {
|
||||
let mut r = Roles::default();
|
||||
for attr in field.attrs.iter().filter(|a| a.path().is_ident("walk")) {
|
||||
let mut sketch = None;
|
||||
let mut when = None;
|
||||
attr.parse_nested_meta(|meta| {
|
||||
let name = meta.path.get_ident().map(Ident::to_string).unwrap_or_default();
|
||||
match name.as_str() {
|
||||
"pick" => r.geo = Some(Geo::Points("Pick")),
|
||||
"pinned" => r.geo = Some(Geo::Points("Pinned")),
|
||||
"pos" => r.geo = Some(Geo::Points("Pos")),
|
||||
"dir" => r.geo = Some(Geo::Points("Dir")),
|
||||
"placement" => r.geo = Some(Geo::Placement),
|
||||
"pick_if" => r.geo = Some(Geo::PickIf(meta.value()?.parse()?)),
|
||||
"profiles_of" => sketch = Some(meta.value()?.parse::<Ident>()?),
|
||||
"when" => when = Some(meta.value()?.parse::<Expr>()?),
|
||||
"length" => r.expr = Some(("Length", meta.value()?.parse()?)),
|
||||
"angle" => r.expr = Some(("Angle", meta.value()?.parse()?)),
|
||||
"unitless" => r.expr = Some(("Unitless", meta.value()?.parse()?)),
|
||||
"sketch" => r.reference = Some("Sketch"),
|
||||
"feature" => r.reference = Some("Feature"),
|
||||
"body" => r.reference = Some("Body"),
|
||||
"after" => r.after = Some(meta.value()?.parse()?),
|
||||
"picks_faces" => r.picks_faces = true,
|
||||
_ => return Err(meta.error("unknown walk role")),
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(sketch) = sketch {
|
||||
r.geo = Some(Geo::Profiles { sketch, when });
|
||||
} else if when.is_some() {
|
||||
return Err(syn::Error::new_spanned(attr, "`when` goes with `profiles_of`"));
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Which {
|
||||
Exprs,
|
||||
Refs,
|
||||
Geometry,
|
||||
}
|
||||
|
||||
/// A value worked out before the walk (a condition, a label, a sketch id) for field `b`.
|
||||
fn pre(b: &Ident, what: &str) -> Ident {
|
||||
format_ident!("__{}_{}", what, b)
|
||||
}
|
||||
|
||||
/// The statements that walk field `b` in walk `which`.
|
||||
fn walk_field(b: &Ident, r: &Roles, which: Which, mutable: bool) -> Tokens {
|
||||
let w = quote!(crate::walk);
|
||||
let each = if mutable { quote!(each_mut) } else { quote!(each) };
|
||||
if r.nested() {
|
||||
let m = match which {
|
||||
Which::Exprs => "walk_exprs",
|
||||
Which::Refs => "walk_refs",
|
||||
Which::Geometry => "walk_geometry",
|
||||
};
|
||||
let m = if mutable { format_ident!("{}_mut", m) } else { format_ident!("{}", m) };
|
||||
if r.picks_faces && which == Which::Geometry {
|
||||
return quote! {
|
||||
#w::Walk::#m(#b, &mut |g| f(match g { #w::Geo::Pos(p) => #w::Geo::Pick(p), g => g }));
|
||||
};
|
||||
}
|
||||
return quote! { #w::Walk::#m(#b, f); };
|
||||
}
|
||||
match which {
|
||||
Which::Exprs => {
|
||||
let label = pre(b, "label");
|
||||
match (&r.expr, &r.geo) {
|
||||
(Some((kind, _)), _) => {
|
||||
let kind = Ident::new(kind, Span::call_site());
|
||||
quote! {
|
||||
#w::ExprField::#each(#b, &mut |i, n, e| f((#w::Label::at(&#label, i, n), e, #w::Kind::#kind, #w::ExprRole::Input)));
|
||||
}
|
||||
}
|
||||
(None, Some(Geo::Placement)) => quote! {
|
||||
#w::ExprField::#each(#b, &mut |i, n, e| f((#w::Label::at(&["X", "Y", "Z"], i, n), e, #w::Kind::Length, #w::ExprRole::Placement)));
|
||||
},
|
||||
_ => quote!(),
|
||||
}
|
||||
}
|
||||
Which::Refs => match r.reference {
|
||||
Some(kind) => {
|
||||
let t = if kind == "Sketch" { quote!(u64) } else { quote!(String) };
|
||||
let kind = Ident::new(kind, Span::call_site());
|
||||
quote! { #w::Each::<#t>::#each(#b, &mut |x| f(#w::Ref::#kind(x))); }
|
||||
}
|
||||
None => quote!(),
|
||||
},
|
||||
Which::Geometry => match &r.geo {
|
||||
Some(Geo::Points(v)) => {
|
||||
let v = Ident::new(v, Span::call_site());
|
||||
quote! { #w::Each::<#w::Vec3>::#each(#b, &mut |p| f(#w::Geo::#v(p))); }
|
||||
}
|
||||
Some(Geo::PickIf(_)) => {
|
||||
let pick = pre(b, "pick");
|
||||
quote! {
|
||||
#w::Each::<#w::Vec3>::#each(#b, &mut |p| f(if #pick { #w::Geo::Pick(p) } else { #w::Geo::Pos(p) }));
|
||||
}
|
||||
}
|
||||
Some(Geo::Placement) => quote! { f(#w::Geo::Placement(#b)); },
|
||||
Some(Geo::Profiles { .. }) => {
|
||||
let (id, when) = (pre(b, "sketch"), pre(b, "when"));
|
||||
quote! {
|
||||
if #when && let Some(id) = #id {
|
||||
#w::Each::<#w::ProfileSel>::#each(#b, &mut |sel| f(#w::Geo::Profiles(id, sel)));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => quote!(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The values field `b`'s walk needs, worked out before the walk starts (the walk may hand out
|
||||
/// the fields they read).
|
||||
fn pre_values(b: &Ident, r: &Roles, which: Which) -> Tokens {
|
||||
let w = quote!(crate::walk);
|
||||
match (which, &r.expr, &r.geo) {
|
||||
(Which::Exprs, Some((_, label)), _) => {
|
||||
let l = pre(b, "label");
|
||||
quote! { let #l = #label; }
|
||||
}
|
||||
(Which::Geometry, _, Some(Geo::PickIf(cond))) => {
|
||||
let p = pre(b, "pick");
|
||||
quote! { let #p: bool = #cond; }
|
||||
}
|
||||
(Which::Geometry, _, Some(Geo::Profiles { sketch, when })) => {
|
||||
let (id, wh) = (pre(b, "sketch"), pre(b, "when"));
|
||||
let when = when.as_ref().map_or(quote!(true), |c| quote!(#c));
|
||||
quote! {
|
||||
let #wh: bool = #when;
|
||||
let mut #id: Option<u64> = None;
|
||||
#w::Each::<u64>::each(&*#sketch, &mut |s| #id = Some(*s));
|
||||
}
|
||||
}
|
||||
_ => quote!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A pattern that binds every field of `fields` by name (`_0`, `_1`… for tuple fields), and the
|
||||
/// body of walk `which` over them.
|
||||
fn arm(fields: &Fields, which: Which, mutable: bool) -> syn::Result<(Tokens, Tokens)> {
|
||||
let mut members = Vec::new();
|
||||
let mut walked: Vec<(Ident, Roles)> = Vec::new();
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
let b = field.ident.clone().unwrap_or_else(|| format_ident!("_{}", i));
|
||||
members.push(field.ident.clone().map_or_else(|| Member::Unnamed(i.into()), Member::Named));
|
||||
walked.push((b, roles(field)?));
|
||||
}
|
||||
let bindings: Vec<Ident> = walked.iter().map(|(b, _)| b.clone()).collect();
|
||||
// `after = x` moves a field to just after `x`.
|
||||
for b in &bindings {
|
||||
let Some(pos) = walked.iter().position(|(x, _)| x == b) else { continue };
|
||||
let Some(after) = walked.get(pos).and_then(|(_, r)| r.after.clone()) else { continue };
|
||||
let moved = walked.remove(pos);
|
||||
let Some(at) = walked.iter().position(|(x, _)| *x == after) else {
|
||||
return Err(syn::Error::new_spanned(after, "no such field"));
|
||||
};
|
||||
walked.insert(at + 1, moved);
|
||||
}
|
||||
let mut body = Tokens::new();
|
||||
for (b, r) in &walked {
|
||||
body.extend(pre_values(b, r, which));
|
||||
}
|
||||
for (b, r) in &walked {
|
||||
body.extend(walk_field(b, r, which, mutable));
|
||||
}
|
||||
let pattern = match fields {
|
||||
Fields::Named(_) => quote!({ #(#bindings),* }),
|
||||
Fields::Unnamed(_) => quote!({ #(#members: #bindings),* }),
|
||||
Fields::Unit => quote!(),
|
||||
};
|
||||
Ok((pattern, body))
|
||||
}
|
||||
|
||||
fn expand(input: &DeriveInput) -> syn::Result<Tokens> {
|
||||
let name = &input.ident;
|
||||
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
|
||||
let w = quote!(crate::walk);
|
||||
let mut methods = Tokens::new();
|
||||
for (which, method, item) in
|
||||
[(Which::Exprs, "walk_exprs", "ExprItem"), (Which::Refs, "walk_refs", "RefItem"), (Which::Geometry, "walk_geometry", "GeoItem")]
|
||||
{
|
||||
for mutable in [false, true] {
|
||||
let body = match &input.data {
|
||||
Data::Struct(s) => {
|
||||
let (pattern, body) = arm(&s.fields, which, mutable)?;
|
||||
quote! { let Self #pattern = self; #body }
|
||||
}
|
||||
Data::Enum(e) => {
|
||||
let mut arms = Tokens::new();
|
||||
for v in &e.variants {
|
||||
let ident = &v.ident;
|
||||
let (pattern, body) = arm(&v.fields, which, mutable)?;
|
||||
arms.extend(quote! { Self::#ident #pattern => { #body } });
|
||||
}
|
||||
quote! { match self { #arms } }
|
||||
}
|
||||
Data::Union(u) => return Err(syn::Error::new_spanned(u.union_token, "unions are not walked")),
|
||||
};
|
||||
let (method, item, r) = if mutable {
|
||||
(format_ident!("{}_mut", method), format_ident!("{}Mut", item), quote!(&'a mut))
|
||||
} else {
|
||||
(format_ident!("{}", method), format_ident!("{}", item), quote!(&'a))
|
||||
};
|
||||
methods.extend(quote! {
|
||||
#[allow(unused_variables, unused_mut, clippy::needless_borrow)]
|
||||
fn #method<'a>(#r self, f: &mut dyn FnMut(#w::#item<'a>)) { #body }
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(quote! {
|
||||
impl #impl_generics #w::Walk for #name #ty_generics #where_clause { #methods }
|
||||
})
|
||||
}
|
||||
@@ -12,6 +12,8 @@ workspace = true
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-sketch = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
solidcraft-render = { workspace = true }
|
||||
solidcraft-derive = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -12,11 +12,34 @@ use crate::Document;
|
||||
|
||||
/// A look: a name (a library entry's, or "Custom"), sRGB colour and opacity (1 = opaque).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "LookWire")]
|
||||
pub struct Look {
|
||||
pub name: String,
|
||||
pub color: [u8; 3],
|
||||
#[serde(default = "opaque")]
|
||||
pub opacity: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pbr: Option<crate::pbr_appearance::PbrParameters>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LookWire {
|
||||
name: String,
|
||||
color: [u8; 3],
|
||||
#[serde(default = "opaque")]
|
||||
opacity: f64,
|
||||
#[serde(default)]
|
||||
pbr: Option<crate::pbr_appearance::PbrParameters>,
|
||||
}
|
||||
|
||||
impl TryFrom<LookWire> for Look {
|
||||
type Error = String;
|
||||
fn try_from(value: LookWire) -> std::result::Result<Self, Self::Error> {
|
||||
if value.pbr.is_some() && value.opacity != 1.0 {
|
||||
return Err("opaque PBR requires opacity 1; refraction/transmission is not supported".into());
|
||||
}
|
||||
Ok(Self { name: value.name, color: value.color, opacity: value.opacity, pbr: value.pbr })
|
||||
}
|
||||
}
|
||||
|
||||
fn opaque() -> f64 {
|
||||
@@ -25,7 +48,13 @@ fn opaque() -> f64 {
|
||||
|
||||
impl Look {
|
||||
pub fn custom(color: [u8; 3], opacity: f64) -> Look {
|
||||
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0) }
|
||||
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0), pbr: None }
|
||||
}
|
||||
pub fn opaque_surface(&self) -> crate::Result<Option<solidcraft_render::surface::OpaqueSurface>> {
|
||||
if self.pbr.is_some() && self.opacity != 1.0 {
|
||||
return Err(crate::DocError::Invalid("opaque PBR requires opacity 1".into()));
|
||||
}
|
||||
self.pbr.map(|p| p.surface(self.color).map_err(|e| crate::DocError::Invalid(e.to_string()))).transpose()
|
||||
}
|
||||
/// Colour as 0–1 fractions.
|
||||
pub fn rgb(&self) -> [f32; 3] {
|
||||
@@ -109,14 +138,19 @@ pub use defaults::LIBRARY;
|
||||
|
||||
/// A library appearance by name (case-insensitive).
|
||||
pub fn library(name: &str) -> Option<Look> {
|
||||
LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look { name: n.to_string(), color: *c, opacity: *o })
|
||||
LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look {
|
||||
name: n.to_string(),
|
||||
color: *c,
|
||||
opacity: *o,
|
||||
pbr: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The look a physical material gives a body that has no appearance of its own.
|
||||
pub fn material_look(material: &str) -> Option<Look> {
|
||||
let color = crate::material_color(material)?;
|
||||
let opacity = if material == "Glass" { 0.25 } else { 1.0 };
|
||||
Some(Look { name: material.to_string(), color, opacity })
|
||||
Some(Look { name: material.to_string(), color, opacity, pbr: None })
|
||||
}
|
||||
|
||||
impl Document {
|
||||
|
||||
@@ -205,6 +205,7 @@ impl FeatureKind {
|
||||
/// Map the feature's world geometry (points, directions, explicit planes) by `m`.
|
||||
pub fn transform_geometry(&mut self, m: &Mat) {
|
||||
use crate::feature_geometry::Geo;
|
||||
use crate::walk::Walk;
|
||||
self.walk_geometry_mut(&mut |g| match g {
|
||||
Geo::Pick(p) | Geo::Pos(p) => *p = apply_point(m, *p),
|
||||
Geo::Dir(v) => *v = apply_vector(m, *v),
|
||||
|
||||
@@ -194,6 +194,7 @@ impl crate::Feature {
|
||||
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
|
||||
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
|
||||
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
|
||||
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => checked_plane(plane, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
@@ -248,6 +249,7 @@ impl crate::Feature {
|
||||
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0)?,
|
||||
K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0)?,
|
||||
K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0)?,
|
||||
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, names, bodies, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, 0)?;
|
||||
|
||||
@@ -125,10 +125,15 @@ fn construct<'a>(st: &'a ModelState, name: &str) -> Result<&'a ConstructGeom> {
|
||||
|
||||
/// The face picked: (body, face index, point on it).
|
||||
fn face_of<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<(&'a ModelBody, usize, Vec3)> {
|
||||
let at = match name.as_deref().and_then(|n| crate::naming::point_on_face(st, n, at)) {
|
||||
Some((q, _)) => q,
|
||||
None => at,
|
||||
};
|
||||
// The named face itself: its nearest point to the old pick can lie on an edge it shares
|
||||
// (a corner round that a parameter moved away from the pick), where picking again by the
|
||||
// point finds the flat face beside it (#1255).
|
||||
if let Some(hit) = name.as_deref().and_then(|n| crate::name_lookup::locate_face(st, n, at))
|
||||
&& let Some(b) = st.bodies.get(hit.body).filter(|b| !b.body.is_mesh())
|
||||
&& hit.face < b.body.face_count()
|
||||
{
|
||||
return Ok((b, hit.face, hit.point));
|
||||
}
|
||||
for b in bodies(st) {
|
||||
if let Some(fi) = crate::appearance::face_index_at(b, at) {
|
||||
return Ok((b, fi, at));
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! How finely the viewport tessellates curved faces and edges (Preferences ▸ Graphics ▸ Display
|
||||
//! quality). A viewing preference, not design data: it only picks the tolerances of the display
|
||||
//! plan ([`crate::ModelBody::display_plan`]) the viewport draws. The reference mesh the model
|
||||
//! reads (picks, holes, measuring) and exports keep their own tolerances.
|
||||
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
/// A display quality level.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum DisplayQuality {
|
||||
Coarse,
|
||||
#[default]
|
||||
Normal,
|
||||
Fine,
|
||||
Ultra,
|
||||
}
|
||||
|
||||
impl DisplayQuality {
|
||||
pub const ALL: [DisplayQuality; 4] = [DisplayQuality::Coarse, DisplayQuality::Normal, DisplayQuality::Fine, DisplayQuality::Ultra];
|
||||
|
||||
/// The id kept in the settings file.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
DisplayQuality::Coarse => "coarse",
|
||||
DisplayQuality::Normal => "normal",
|
||||
DisplayQuality::Fine => "fine",
|
||||
DisplayQuality::Ultra => "ultra",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(id: &str) -> Option<DisplayQuality> {
|
||||
Self::ALL.into_iter().find(|q| q.id() == id)
|
||||
}
|
||||
|
||||
/// Position in [`DisplayQuality::ALL`] (indexes the per-level display caches).
|
||||
pub fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
|
||||
/// Chord tolerance as a fraction of the body's size: the largest gap allowed between a
|
||||
/// facet and its surface.
|
||||
pub fn chord_fraction(self) -> f64 {
|
||||
match self {
|
||||
DisplayQuality::Coarse => 3e-3,
|
||||
DisplayQuality::Normal => 1e-3,
|
||||
DisplayQuality::Fine => 3e-4,
|
||||
DisplayQuality::Ultra => 1e-4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Smallest chord tolerance (mm), so a tiny body does not mesh down to nothing.
|
||||
pub fn min_chord(self) -> f64 {
|
||||
match self {
|
||||
DisplayQuality::Coarse => 3e-3,
|
||||
DisplayQuality::Normal => 1e-3,
|
||||
DisplayQuality::Fine => 3e-4,
|
||||
DisplayQuality::Ultra => 1e-4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Most a facet of a curved face or edge may turn (radians), so a small radius on a large
|
||||
/// body still looks round.
|
||||
pub fn angle(self) -> f64 {
|
||||
let deg: f64 = match self {
|
||||
DisplayQuality::Coarse => 30.0,
|
||||
DisplayQuality::Normal => 20.0,
|
||||
DisplayQuality::Fine => 12.0,
|
||||
DisplayQuality::Ultra => 7.0,
|
||||
};
|
||||
deg.to_radians()
|
||||
}
|
||||
|
||||
/// The chord tolerance (mm) for a body of this size.
|
||||
pub fn chord(self, size: f64) -> f64 {
|
||||
let size = if size.is_finite() && size > 0.0 { size } else { 0.0 };
|
||||
(size * self.chord_fraction()).max(self.min_chord())
|
||||
}
|
||||
}
|
||||
|
||||
static CURRENT: AtomicU8 = AtomicU8::new(1);
|
||||
|
||||
/// The level the viewport draws at (process-wide; set from the preferences).
|
||||
pub fn current() -> DisplayQuality {
|
||||
DisplayQuality::ALL.get(CURRENT.load(Ordering::Relaxed) as usize).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set the level the viewport draws at. Bodies keep a display plan per level, so switching back
|
||||
/// is instant and nothing is re-evaluated.
|
||||
pub fn set_current(q: DisplayQuality) {
|
||||
CURRENT.store(q.index() as u8, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normal_is_the_application_tolerance_and_levels_get_finer() {
|
||||
let n = DisplayQuality::Normal;
|
||||
assert_eq!(n.chord(200.0), 0.2);
|
||||
assert_eq!(n.chord(0.5), 1e-3);
|
||||
assert!((n.angle() - solidcraft_kernel::DISPLAY_ANGLE).abs() < 1e-12);
|
||||
for w in DisplayQuality::ALL.windows(2) {
|
||||
assert!(w[1].chord(100.0) < w[0].chord(100.0) && w[1].angle() < w[0].angle());
|
||||
}
|
||||
for q in DisplayQuality::ALL {
|
||||
assert_eq!(DisplayQuality::parse(q.id()), Some(q));
|
||||
assert_eq!(DisplayQuality::ALL[q.index()], q);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each level keeps its own plan: finer levels draw a round body with more triangles and
|
||||
/// closer to the surface, and the reference mesh the model reads never changes.
|
||||
#[test]
|
||||
fn each_level_has_its_own_plan_and_the_reference_mesh_stays() {
|
||||
use solidcraft_geom::Vec3;
|
||||
let body = solidcraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 20.0, 50.0).unwrap();
|
||||
let mb = crate::ModelBody::new("Body1".into(), body, 1);
|
||||
let reference = mb.mesh();
|
||||
let mut last = 0;
|
||||
for q in DisplayQuality::ALL {
|
||||
let mesh = mb.plan_mesh_at(q).unwrap();
|
||||
let r = |v: Vec3| (v.x * v.x + v.y * v.y).sqrt();
|
||||
// The worst chord's sag on the side wall, from its triangle centres.
|
||||
let sag = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.map(|t| t.map(|i| mesh.positions[i as usize]))
|
||||
.filter(|t| t.iter().all(|p| (r(*p) - 20.0).abs() < 1e-9))
|
||||
.map(|[a, b, c]| 20.0 - r((a + b + c) * (1.0 / 3.0)))
|
||||
.fold(0.0, f64::max);
|
||||
assert!(sag <= q.chord(mb.body.size()) * 1.01, "{q:?}: sag {sag}");
|
||||
assert!(mesh.triangles.len() > last, "{q:?}: {} triangles", mesh.triangles.len());
|
||||
last = mesh.triangles.len();
|
||||
assert!(std::sync::Arc::ptr_eq(&mesh, &mb.plan_mesh_at(q).unwrap()), "made once per level");
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&reference, &mb.mesh()));
|
||||
assert_eq!(mb.display_plan_at(DisplayQuality::Normal).unwrap().vertex_count(), {
|
||||
let plan = mb.body.display_plan_angular((mb.body.size() * 1e-3).max(1e-3), solidcraft_kernel::DISPLAY_ANGLE).unwrap();
|
||||
plan.vertex_count()
|
||||
});
|
||||
}
|
||||
}
|
||||
+449
-18
File diff suppressed because it is too large.
Load diff
+52
-20
@@ -30,10 +30,10 @@ pub struct ModelBody {
|
||||
mesh: Arc<OnceLock<Arc<Mesh>>>,
|
||||
/// The display plan (none when the body has no plan: a mesh body, or one the planner
|
||||
/// refuses), shared like the mesh. A GPU viewport draws it.
|
||||
plan: Arc<OnceLock<Option<Arc<TessPlan>>>>,
|
||||
plan: Arc<[OnceLock<Option<Arc<TessPlan>>>; 4]>,
|
||||
/// The plan evaluated on the CPU (none without a plan), shared like the mesh: the triangles a
|
||||
/// GPU viewport draws, so a highlight over a face lies exactly on it.
|
||||
plan_mesh: Arc<OnceLock<Option<Arc<Mesh>>>>,
|
||||
plan_mesh: Arc<[OnceLock<Option<Arc<Mesh>>>; 4]>,
|
||||
/// Identity of this body's content for the evaluation cache: the key of the evaluation
|
||||
/// that produced it (kept while later features pass it through unchanged).
|
||||
key: u64,
|
||||
@@ -61,8 +61,8 @@ impl ModelBody {
|
||||
body,
|
||||
feature,
|
||||
mesh: Arc::new(OnceLock::new()),
|
||||
plan: Arc::new(OnceLock::new()),
|
||||
plan_mesh: Arc::new(OnceLock::new()),
|
||||
plan: Arc::new(Default::default()),
|
||||
plan_mesh: Arc::new(Default::default()),
|
||||
key: 0,
|
||||
names: Default::default(),
|
||||
made: Default::default(),
|
||||
@@ -88,11 +88,19 @@ impl ModelBody {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The display plan of this body (the same tolerance and angle as [`ModelBody::mesh`]), made
|
||||
/// once; `None` when the body has no plan (a mesh body, or one the planner refuses).
|
||||
/// The display plan of this body at the viewport's display quality
|
||||
/// ([`crate::display_quality::current`]; at Normal, the same tolerance and angle as
|
||||
/// [`ModelBody::mesh`]), made once per level; `None` when the body has no plan (a mesh body,
|
||||
/// or one the planner refuses).
|
||||
pub fn display_plan(&self) -> Option<Arc<TessPlan>> {
|
||||
self.display_plan_at(crate::display_quality::current())
|
||||
}
|
||||
|
||||
/// The display plan at a given display quality, made once per level.
|
||||
pub fn display_plan_at(&self, q: crate::display_quality::DisplayQuality) -> Option<Arc<TessPlan>> {
|
||||
self.plan
|
||||
.get_or_init(|| match self.body.display_plan_angular(self.display_tol(), kernel::DISPLAY_ANGLE) {
|
||||
.get(q.index())?
|
||||
.get_or_init(|| match self.body.display_plan_angular(q.chord(self.body.size()), q.angle()) {
|
||||
Ok(plan) => Some(Arc::new(plan)),
|
||||
Err(e) => {
|
||||
log::debug!("{}: no display plan ({e})", self.name);
|
||||
@@ -102,12 +110,20 @@ impl ModelBody {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The display plan's mesh, evaluated on the CPU once: the same triangles a GPU viewport
|
||||
/// evaluates and draws for this body. Highlights over a face drawn from the plan use it, since
|
||||
/// the reference mesh ([`ModelBody::mesh`]) triangulates the face differently and the two
|
||||
/// would fight at equal depth. `None` when the body has no plan.
|
||||
/// The display plan's mesh, evaluated on the CPU once per level: the same triangles a GPU
|
||||
/// viewport evaluates and draws for this body. Highlights over a face drawn from the plan use
|
||||
/// it, since the reference mesh ([`ModelBody::mesh`]) triangulates the face differently and
|
||||
/// the two would fight at equal depth. `None` when the body has no plan.
|
||||
pub fn plan_mesh(&self) -> Option<Arc<Mesh>> {
|
||||
self.plan_mesh.get_or_init(|| self.display_plan().and_then(|plan| self.body.display_mesh_of(&plan).ok()).map(Arc::new)).clone()
|
||||
self.plan_mesh_at(crate::display_quality::current())
|
||||
}
|
||||
|
||||
/// The display plan's mesh at a given display quality.
|
||||
pub fn plan_mesh_at(&self, q: crate::display_quality::DisplayQuality) -> Option<Arc<Mesh>> {
|
||||
self.plan_mesh
|
||||
.get(q.index())?
|
||||
.get_or_init(|| self.display_plan_at(q).and_then(|plan| self.body.display_mesh_of(&plan).ok()).map(Arc::new))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The display mesh's chord tolerance: 1/1000 of the body's size.
|
||||
@@ -1281,6 +1297,17 @@ fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
|
||||
Document::eval_in(vals, e, k)
|
||||
}
|
||||
|
||||
/// A loft builds each section from its outline alone, so a section with a hole (a ring) is
|
||||
/// refused rather than lofted as if it were filled.
|
||||
fn refuse_loft_holes(r: &Region2) -> Result<()> {
|
||||
if r.holes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(DocError::Invalid(
|
||||
"not supported yet: a loft section with a hole; loft the outline and the hole as two bodies and cut one from the other".into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region2>> {
|
||||
let ps = &ss.profiles;
|
||||
if ps.is_empty() {
|
||||
@@ -2656,6 +2683,8 @@ fn feature_tools_with_constructor(
|
||||
];
|
||||
// How far a tool grows past its profile at either end, at most.
|
||||
let mut growth: f64 = 0.0;
|
||||
// A face of the targets in the profile's plane (the same for every profile).
|
||||
let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, targets);
|
||||
let tools: Vec<Traced> = regions
|
||||
.iter()
|
||||
.map(|r| {
|
||||
@@ -2669,12 +2698,7 @@ fn feature_tools_with_constructor(
|
||||
// A start that can't run on (the profile hangs over the face's edge):
|
||||
// the sides stand straight for a hair first, so the boolean can push the
|
||||
// face the tool stands on into the body.
|
||||
let foot = if a >= 0.0 && matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, targets)
|
||||
{
|
||||
TAPER_FOOT.min((b - a) * 0.01)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let foot = if a >= 0.0 && on_face { TAPER_FOOT.min((b - a) * 0.01) } else { 0.0 };
|
||||
let body = kernel::extrude_tapered_between(&start_plane, r, a, b, foot, sign, taper)?;
|
||||
growth = growth.max(a.abs().max(b.abs()) * taper.tan().abs());
|
||||
Ok(crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() }.tool(body, None))
|
||||
@@ -2953,6 +2977,7 @@ fn feature_tools_with_constructor(
|
||||
let ss = st.sketch(prof.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", prof.sketch)))?;
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &prof.profiles)?);
|
||||
let [r] = ®ions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
|
||||
refuse_loft_holes(r)?;
|
||||
let ps = st.sketch(pt.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", pt.sketch)))?;
|
||||
let id = pt.point.as_deref().unwrap_or_default();
|
||||
let q = ps.sketch.points.iter().find(|p| p.id == id).ok_or_else(|| DocError::Unknown(format!("point `{id}` in {}", ps.name)))?;
|
||||
@@ -3008,6 +3033,7 @@ fn feature_tools_with_constructor(
|
||||
.ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?;
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &sec.profiles)?);
|
||||
let [r] = ®ions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
|
||||
refuse_loft_holes(r)?;
|
||||
secs.push((ss.plane, r.outer.clone()));
|
||||
sketches.push(ss);
|
||||
}
|
||||
@@ -3281,7 +3307,11 @@ pub(crate) fn extent_sides(vals: &BTreeMap<String, Value>, extent: &crate::Exten
|
||||
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
|
||||
(None, crate::Direction::Positive) => (0.0, d),
|
||||
(None, crate::Direction::Negative) => (-d, 0.0),
|
||||
(None, crate::Direction::Symmetric) => (-d, d),
|
||||
(None, crate::Direction::Symmetric) => {
|
||||
// Through All is a reach computed from target bodies, not an entered total.
|
||||
let half = if extent.through_all { d } else { extent.convention.symmetric_half_distance(d) };
|
||||
(-half, half)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3928,7 +3958,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::SurfaceRevolve { .. }
|
||||
| FeatureKind::SurfaceSweep { .. }
|
||||
| FeatureKind::SurfaceLoft { .. }
|
||||
| FeatureKind::SurfaceOffset { .. } => surface::eval(doc, vals, f, st),
|
||||
| FeatureKind::SurfaceOffset { .. }
|
||||
| FeatureKind::SurfaceRuled { .. }
|
||||
| FeatureKind::SurfaceUntrim { .. } => surface::eval(doc, vals, f, st),
|
||||
FeatureKind::Emboss { .. }
|
||||
| FeatureKind::Rib { .. }
|
||||
| FeatureKind::ReplaceFace { .. }
|
||||
|
||||
+207
-68
@@ -157,16 +157,25 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit outward normal of the planar face through `p`.
|
||||
/// Unit outward normal of the planar face through `p`: of the planar faces whose plane runs
|
||||
/// through it, the one nearest it. A face elsewhere in such a plane (the wall of a notch, the end
|
||||
/// of a rib, in line with a rim) is no pick of `p`, though its plane runs through it as exactly.
|
||||
fn planar_normal_at(b: &Body, p: Vec3) -> Option<Vec3> {
|
||||
let tol = (b.size() * 2e-3).max(1e-3);
|
||||
let faces = b.faces(tol).ok()?;
|
||||
let mesh = b.tessellate(tol).ok()?;
|
||||
let mut near = vec![f64::INFINITY; faces.len()];
|
||||
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if let (Some([u, v, w]), Some(d)) = (mesh.tri(t), near.get_mut(*f as usize)) {
|
||||
*d = d.min(crate::appearance::closest_on_triangle(p, u, v, w).dist(p));
|
||||
}
|
||||
}
|
||||
faces
|
||||
.iter()
|
||||
.filter_map(|f| f.plane_normal.map(|n| (n, ((p - f.centroid).dot(n)).abs())))
|
||||
.filter(|(_, d)| *d < tol * 5.0)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(n, _)| n)
|
||||
.filter_map(|f| f.plane_normal.map(|n| (n, ((p - f.centroid).dot(n)).abs(), near.get(f.index).copied().unwrap_or(f64::INFINITY))))
|
||||
.filter(|(_, d, _)| *d < tol * 5.0)
|
||||
.min_by(|a, b| a.2.total_cmp(&b.2).then(a.1.total_cmp(&b.1)))
|
||||
.map(|(n, _, _)| n)
|
||||
}
|
||||
|
||||
/// The curves of a chain as one polyline in sketch coordinates, in order.
|
||||
@@ -637,16 +646,18 @@ pub(super) fn sheet_eval(
|
||||
// along a round wall an arc of the wall's circle (not chords: the plate check below
|
||||
// compares its area with the exact volume).
|
||||
let plane = Plane::from_normal(*face + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
|
||||
// Recesses in one face (an engraved label) become pockets; the outline is the other face's.
|
||||
let recesses = plate_recesses(&mb.body, &mesh, &plane, *face, n, (lo, hi), tol)?;
|
||||
let mut walls = std::collections::HashMap::new();
|
||||
let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| round_wall(&mb.body, &plane, n, f));
|
||||
let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| sheet::round_wall(&mb.body, &plane, n, f));
|
||||
let mut loops: Vec<Loop2> =
|
||||
face_outline(&mesh, &plane, *face, n, lo, tol).iter().map(|l| outline_loop(l, &mut wall, tol * 1e-2)).collect();
|
||||
face_outline(&mesh, &plane, *face, n, recesses.level, tol).iter().map(|l| sheet::outline_loop(l, &mut wall, tol * 1e-2)).collect();
|
||||
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
|
||||
let mut it = loops.into_iter();
|
||||
let outer = it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?.ccw();
|
||||
let region = Region2 { outer, holes: it.map(|l| l.ccw().reversed()).collect() };
|
||||
let area = region.area().abs();
|
||||
let vol = kernel::measure(&mb.body)?.volume;
|
||||
let vol = kernel::measure(&mb.body)?.volume + recesses.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 without bend edges (`bends`)".into(),
|
||||
@@ -657,6 +668,10 @@ pub(super) fn sheet_eval(
|
||||
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 mut work = sheet::FlatWork::new();
|
||||
for (pocket, z0, z1) in recesses.pockets {
|
||||
sh.record_pocket(pocket, z0, z1, &mut work)?;
|
||||
}
|
||||
put_sheet(st, sh)
|
||||
}
|
||||
_ => Err(DocError::Invalid(format!("{} is not a sheet metal feature", f.name))),
|
||||
@@ -720,7 +735,11 @@ fn sheet_tool_span(body: &Body, source: &Plane) -> Option<(f64, f64, f64, Option
|
||||
}
|
||||
// Bound floating point projection separately from model geometry tolerance.
|
||||
let roundoff = 128.0 * f64::EPSILON * (plane.origin.len() + current.origin.len() + source.origin.len()).max(1.0);
|
||||
if roundoff > 1e-7 || (current.origin - plane.origin).dot(n).abs() > roundoff {
|
||||
// The cap's current point lies off the recorded plane by the noise of evaluating
|
||||
// its edges, which its fitted tilt shows across the distance between the two points
|
||||
// (8e-12 over 2.4 mm on a 45° flange's drill, #1255; none on axis-aligned caps).
|
||||
let noise = current.normal().cross(n).len() * (current.origin - plane.origin).len();
|
||||
if roundoff > 1e-7 || (current.origin - plane.origin).dot(n).abs() > roundoff + noise {
|
||||
return None;
|
||||
}
|
||||
projection_roundoff = projection_roundoff.max(roundoff);
|
||||
@@ -1121,18 +1140,8 @@ fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le
|
||||
fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<(Vec2, Option<u32>)>> {
|
||||
let on_plane = |t: &[u32; 3]| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - at).dot(n) - level).abs() < tol));
|
||||
let on: Vec<[u32; 3]> = mesh.triangles.iter().copied().filter(on_plane).collect();
|
||||
// Boundary edges by position (meshes may repeat vertices per triangle).
|
||||
let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default();
|
||||
let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64);
|
||||
let key = |a: Vec3, b: Vec3| {
|
||||
let (ka, kb) = (q(a), q(b));
|
||||
(ka.min(kb), ka.max(kb))
|
||||
};
|
||||
let mut count: std::collections::HashMap<((i64, i64, i64), (i64, i64, i64)), i32> = Default::default();
|
||||
let edges: Vec<(Vec3, Vec3)> = on.iter().flat_map(|t| [(pos(t[0]), pos(t[1])), (pos(t[1]), pos(t[2])), (pos(t[2]), pos(t[0]))]).collect();
|
||||
for (a, b) in &edges {
|
||||
*count.entry(key(*a, *b)).or_insert(0) += 1;
|
||||
}
|
||||
// The faces off the plane at each point on it, by position to a millionth of a millimetre
|
||||
// either way (a neighbouring face's mesh may round a shared point differently).
|
||||
let mut touch: std::collections::HashMap<(i64, i64, i64), Vec<u32>> = Default::default();
|
||||
@@ -1156,6 +1165,34 @@ fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3,
|
||||
let at_b = near(b);
|
||||
near(a).into_iter().find(|f| at_b.contains(f))
|
||||
};
|
||||
chain_loops(mesh, plane, &on, across)
|
||||
}
|
||||
|
||||
/// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh.
|
||||
fn boundary_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, on: &[[u32; 3]]) -> Vec<Vec<Vec2>> {
|
||||
chain_loops(mesh, plane, on, |_, _| None).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect()
|
||||
}
|
||||
|
||||
/// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh, each point with
|
||||
/// `across(it, next)`: the face the outline runs along from it to the next point.
|
||||
fn chain_loops(
|
||||
mesh: &solidcraft_geom::Mesh,
|
||||
plane: &Plane,
|
||||
on: &[[u32; 3]],
|
||||
across: impl Fn(Vec3, Vec3) -> Option<u32>,
|
||||
) -> Vec<Vec<(Vec2, Option<u32>)>> {
|
||||
// Boundary edges by position (meshes may repeat vertices per triangle).
|
||||
let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default();
|
||||
let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64);
|
||||
let key = |a: Vec3, b: Vec3| {
|
||||
let (ka, kb) = (q(a), q(b));
|
||||
(ka.min(kb), ka.max(kb))
|
||||
};
|
||||
let mut count: std::collections::HashMap<((i64, i64, i64), (i64, i64, i64)), i32> = Default::default();
|
||||
let edges: Vec<(Vec3, Vec3)> = on.iter().flat_map(|t| [(pos(t[0]), pos(t[1])), (pos(t[1]), pos(t[2])), (pos(t[2]), pos(t[0]))]).collect();
|
||||
for (a, b) in &edges {
|
||||
*count.entry(key(*a, *b)).or_insert(0) += 1;
|
||||
}
|
||||
let mut boundary: Vec<(Vec3, Vec3)> = edges.into_iter().filter(|(a, b)| count.get(&key(*a, *b)) == Some(&1)).collect();
|
||||
// Chain into loops (plane coordinates).
|
||||
let mut loops: Vec<Vec<(Vec2, Option<u32>)>> = Vec::new();
|
||||
@@ -1194,60 +1231,162 @@ fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3,
|
||||
loops
|
||||
}
|
||||
|
||||
/// The circle (centre in `plane` coordinates, radius) of face `f` when it is a round wall
|
||||
/// standing square on the plane: a cylinder about the plane's normal `n`.
|
||||
fn round_wall(body: &Body, plane: &Plane, n: Vec3, f: u32) -> Option<(Vec2, f64)> {
|
||||
match kernel::face_surface(body, f as usize)? {
|
||||
kernel::FaceSurface::Cylinder { origin, axis, radius } if axis.cross(n).len() < 1e-6 => Some((plane.to_local(origin), radius)),
|
||||
_ => None,
|
||||
}
|
||||
/// What the recesses of a plate (an engraved label, a counterbore) take out of it, for Convert
|
||||
/// to Sheet Metal: each becomes a pocket of the sheet, from its floor to the face it opens on.
|
||||
struct Recesses {
|
||||
/// The level (along the picked face's normal) of the plate's face without recesses, whose
|
||||
/// outline the sheet takes.
|
||||
level: f64,
|
||||
/// Pockets in flat coordinates, with their depth interval in the sheet (0 to its thickness).
|
||||
pockets: Vec<(Region2, f64, f64)>,
|
||||
/// The material they take out.
|
||||
volume: f64,
|
||||
}
|
||||
|
||||
/// A loop of [`face_outline`] as segments: each run along one round wall (`wall` gives a face's
|
||||
/// circle) is an arc of that circle, the rest stays straight. Coaxial cylinders of one radius are
|
||||
/// one wall, however many faces it comes in (many systems write a round hole's wall as two
|
||||
/// half-cylinders), so such a hole is one circle. `eps`: how far off its circle a point may be.
|
||||
fn outline_loop(pts: &[(Vec2, Option<u32>)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Loop2 {
|
||||
use std::f64::consts::{PI, TAU};
|
||||
let m = pts.len();
|
||||
let point = |i: usize| pts.get(i % m).map(|x| x.0).unwrap_or_default();
|
||||
// The circle each segment runs along: its wall's, when both its ends lie on it.
|
||||
let circles: Vec<Option<(Vec2, f64)>> = (0..m)
|
||||
.map(|i| {
|
||||
let (c, r) = wall(pts.get(i)?.1?)?;
|
||||
((point(i).dist(c) - r).abs() <= eps && (point(i + 1).dist(c) - r).abs() <= eps).then_some((c, r))
|
||||
})
|
||||
.collect();
|
||||
let circle = |i: usize| circles.get(i % m).copied().flatten();
|
||||
let same = |i: usize, (c, r): (Vec2, f64)| circle(i).is_some_and(|(d, s)| d.dist(c) <= eps && (s - r).abs() <= eps);
|
||||
// Start where no arc runs on across the start (anywhere when the whole loop is one circle).
|
||||
let start = (0..m).find(|i| circle(*i + m - 1).is_none_or(|c| !same(*i, c))).unwrap_or(0);
|
||||
let mut segs = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < m {
|
||||
let k = start + i;
|
||||
let Some((c, r)) = circle(k) else {
|
||||
segs.push(Seg2::Line { a: point(k), b: point(k + 1) });
|
||||
i += 1;
|
||||
/// A planar face of the mesh parallel to the plate: its lowest and highest level, whether its
|
||||
/// outward normal is the picked face's (it faces up), its area, a point on it, and its
|
||||
/// triangles' centres and areas.
|
||||
struct LevelFace {
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
up: bool,
|
||||
area: f64,
|
||||
at: Vec3,
|
||||
tris: Vec<(Vec3, f64)>,
|
||||
}
|
||||
|
||||
/// The recesses of a plate lying between levels `lo` and `hi` along `n` (measured from `at`),
|
||||
/// whose sheet is built in `plane`. A recess's floor is a face parallel to the plate inside its
|
||||
/// thickness, lying in an opening of the face it is cut into, and its walls are square to the
|
||||
/// plate (walls that are not show as material the plate's volume doesn't account for). A body
|
||||
/// that is no plate once its recesses are filled has none, so the plate check refuses it as
|
||||
/// before: a bent part, whose legs' faces lie inside its extent. Recesses are taken in one face
|
||||
/// only, up to half the thickness deep, and no larger than the face they are cut into (a plate
|
||||
/// raised in places is no plate of the raised thickness).
|
||||
fn plate_recesses(body: &Body, mesh: &Mesh, plane: &Plane, at: Vec3, n: Vec3, (lo, hi): (f64, f64), tol: f64) -> Result<Recesses> {
|
||||
let t = hi - lo;
|
||||
let eps = t * 1e-3;
|
||||
let level = |p: Vec3| (p - at).dot(n);
|
||||
let mut faces: BTreeMap<u32, Option<LevelFace>> = BTreeMap::new();
|
||||
for (tri, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
let Some([a, b, c]) = mesh.tri(tri) else { continue };
|
||||
let cr = (b - a).cross(c - a);
|
||||
let len = cr.len();
|
||||
if !(len > 1e-12) {
|
||||
continue;
|
||||
};
|
||||
let mut j = i + 1;
|
||||
while j < m && same(start + j, (c, r)) {
|
||||
j += 1;
|
||||
}
|
||||
let sweep: f64 = (i..j)
|
||||
.map(|s| {
|
||||
let d = (point(start + s + 1) - c).angle() - (point(start + s) - c).angle();
|
||||
(d + PI).rem_euclid(TAU) - PI
|
||||
})
|
||||
.sum();
|
||||
// Arcs of up to half a turn each.
|
||||
let pieces = ((sweep.abs() / PI - 1e-9).ceil() as usize).clamp(1, 4);
|
||||
let (a0, step) = ((point(k) - c).angle(), sweep / pieces as f64);
|
||||
segs.extend((0..pieces).map(|p| Seg2::Arc { center: c, radius: r, start: a0 + step * p as f64, sweep: step }));
|
||||
i = j;
|
||||
let up = cr.dot(n) > 0.0;
|
||||
let entry = faces.entry(*f).or_insert(Some(LevelFace { lo: f64::MAX, hi: f64::MIN, up, area: 0.0, at: a, tris: Vec::new() }));
|
||||
match entry {
|
||||
Some(face) if face.up == up && cr.dot(n).abs() >= len * (1.0 - 1e-6) => {
|
||||
for p in [a, b, c] {
|
||||
face.lo = face.lo.min(level(p));
|
||||
face.hi = face.hi.max(level(p));
|
||||
}
|
||||
face.area += len / 2.0;
|
||||
face.tris.push(((a + b + c) / 3.0, len / 2.0));
|
||||
}
|
||||
_ => *entry = None,
|
||||
}
|
||||
}
|
||||
Loop2 { segs }
|
||||
// The floors (face index, level, the face), and the areas of the plate's two faces (at `lo`
|
||||
// and at `hi`).
|
||||
let mut floors: Vec<(usize, f64, &LevelFace)> = Vec::new();
|
||||
let (mut bottom, mut top) = (0.0, 0.0);
|
||||
for (f, face) in faces.iter().filter_map(|(f, face)| Some((*f as usize, face.as_ref()?))) {
|
||||
let d = (face.lo + face.hi) / 2.0;
|
||||
if face.hi - face.lo > eps {
|
||||
continue;
|
||||
} else if (d - hi).abs() <= eps && face.up {
|
||||
top += face.area;
|
||||
} else if (d - lo).abs() <= eps && !face.up {
|
||||
bottom += face.area;
|
||||
} else if d > lo + eps && d < hi - eps {
|
||||
floors.push((f, d, face));
|
||||
}
|
||||
}
|
||||
let none = || Recesses { level: lo, pockets: Vec::new(), volume: 0.0 };
|
||||
if floors.is_empty() {
|
||||
return Ok(none());
|
||||
}
|
||||
// A floor is a recess's where (most of) it lies in an opening of the face it is cut into:
|
||||
// inside that face's outline and in a hole of it, not on an island in the hole. (Its
|
||||
// triangles are those at its level within `eps`: `tol` may exceed a recess's depth.)
|
||||
let loops_at = |level: f64| -> Vec<Loop2> { face_loops(mesh, plane, at, n, level, eps).into_iter().map(|l| Loop2::polygon(&l)).collect() };
|
||||
let (top_loops, bottom_loops) = (loops_at(hi), loops_at(lo));
|
||||
floors.retain(|(_, _, face)| {
|
||||
let loops = if face.up { &top_loops } else { &bottom_loops };
|
||||
let open = |p: Vec2| {
|
||||
let k = loops.iter().filter(|l| l.contains(p)).count();
|
||||
k >= 2 && k.is_multiple_of(2)
|
||||
};
|
||||
face.tris.iter().filter(|(c, _)| open(plane.to_local(*c))).map(|(_, a)| a).sum::<f64>() > face.area / 2.0
|
||||
});
|
||||
let Some(&(_, _, first)) = floors.first() else { return Ok(none()) };
|
||||
// Filled, the recesses leave a plate: its outline as large at `lo` as at `hi` once their
|
||||
// openings count, and its volume with theirs the outline times the thickness. Else (a bent
|
||||
// part) they are none.
|
||||
let depth_in = |d: f64, up: bool| if up { hi - d } else { d - lo };
|
||||
let opened = |up: bool| floors.iter().filter(|f| f.2.up == up).map(|f| f.2.area).sum::<f64>();
|
||||
let outline = bottom + opened(false);
|
||||
let removed: f64 = floors.iter().map(|f| f.2.area * depth_in(f.1, f.2.up)).sum();
|
||||
let volume = kernel::measure(body)?.volume;
|
||||
if (outline - top - opened(true)).abs() > 1e-3 * outline || (outline * t - volume - removed).abs() > 1e-3 * volume.max(1.0) {
|
||||
return Ok(none());
|
||||
}
|
||||
let pt = |p: Vec3| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z);
|
||||
let up = first.up;
|
||||
if let Some(other) = floors.iter().find(|f| f.2.up != up) {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"not supported yet: converting a plate with recesses in both faces (at {} and {}); recesses in one face become pockets",
|
||||
pt(first.at),
|
||||
pt(other.2.at)
|
||||
)));
|
||||
}
|
||||
let depth_of = |d: f64| depth_in(d, up);
|
||||
if let Some(deep) = floors.iter().find(|f| depth_of(f.1) > t / 2.0 + eps) {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"not supported yet: the recess at {} is {:.3} mm deep, more than half the plate's {:.3} mm; shallower recesses (an engraving, a label) become pockets",
|
||||
pt(deep.2.at),
|
||||
depth_of(deep.1),
|
||||
t
|
||||
)));
|
||||
}
|
||||
let face_area = if up { top } else { bottom };
|
||||
if floors.iter().map(|f| f.2.area).sum::<f64>() > face_area {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"not supported yet: converting a plate whose recesses are larger than the face they are in (a plate thinner than {t:.3} mm, raised in places)"
|
||||
)));
|
||||
}
|
||||
// The floors' outlines, from a mesh fine enough to mark them by.
|
||||
let fine = body.tessellate((t * 1e-2).min(tol).max(1e-4))?;
|
||||
floors.sort_by(|a, b| depth_of(a.1).total_cmp(&depth_of(b.1)));
|
||||
// Floors at one depth are one pocket where they meet.
|
||||
let mut groups: Vec<(f64, Vec<Region2>)> = Vec::new();
|
||||
for (f, d, ..) in &floors {
|
||||
let on: Vec<[u32; 3]> = fine.triangles.iter().zip(&fine.tri_face).filter(|(_, g)| **g as usize == *f).map(|(tri, _)| *tri).collect();
|
||||
let mut loops = boundary_loops(&fine, plane, &on);
|
||||
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
|
||||
let mut it = loops.into_iter();
|
||||
let Some(outline) = it.next() else { continue };
|
||||
let region = Region2 { outer: Loop2::polygon(&outline).ccw(), holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() };
|
||||
match groups.last_mut() {
|
||||
Some((depth, regions)) if depth_of(*d) - *depth <= eps => regions.push(region),
|
||||
_ => groups.push((depth_of(*d), vec![region])),
|
||||
}
|
||||
}
|
||||
// The sheet's z starts from the picked face when its plane faces away from that face.
|
||||
let from_zero = up == (plane.normal().dot(n) < 0.0);
|
||||
let mut work = sheet::FlatWork::new();
|
||||
let (mut pockets, mut volume) = (Vec::new(), 0.0);
|
||||
for (depth, regions) in groups {
|
||||
let (z0, z1) = if from_zero { (0.0, depth) } else { (t - depth, t) };
|
||||
for region in sheet::material_union(regions, &mut work)? {
|
||||
volume += region.area().abs() * depth;
|
||||
pockets.push((region, z0, z1));
|
||||
}
|
||||
}
|
||||
Ok(Recesses { level: if up { lo } else { hi }, pockets, volume })
|
||||
}
|
||||
|
||||
/// The body a placed plastic feature joins: by name, else the one under the point.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend, Surface Extrude,
|
||||
//! Revolve, Sweep and Loft, Offset, Unstitch and Reverse Normal (kernel `surfaces`,
|
||||
//! `surface_offset`).
|
||||
//! Revolve, Sweep and Loft, Offset, Ruled, Untrim, Unstitch and Reverse Normal (kernel `surfaces`,
|
||||
//! `surface_offset`, `surface_ruled`, `surface_untrim`).
|
||||
|
||||
use super::*;
|
||||
use crate::{RuledCorners, RuledMode};
|
||||
use solidcraft_geom::Seg2;
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
@@ -77,11 +78,30 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceExtend { body, edges, distance } => {
|
||||
let d = val(vals, distance, Kind::Length)?;
|
||||
FeatureKind::SurfaceExtend { body, edges, distance, mode, up_to, neighbours } => {
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let out = kernel::extend(&mb.body, edges, d)?;
|
||||
let mode = match mode {
|
||||
crate::ExtendMode::Natural => kernel::ExtendMode::Natural,
|
||||
crate::ExtendMode::Tangent => kernel::ExtendMode::Tangent,
|
||||
crate::ExtendMode::Curvature => kernel::ExtendMode::Curvature,
|
||||
};
|
||||
let at = |limit| kernel::ExtendOptions { mode, limit, neighbours: *neighbours };
|
||||
let out = match up_to {
|
||||
None => kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::Distance(val(vals, distance, Kind::Length)?)))?,
|
||||
Some(crate::ExtendTo::Bounds) => kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::Bounds))?,
|
||||
Some(crate::ExtendTo::Plane(plane)) => {
|
||||
let tool = kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?);
|
||||
kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::UpTo(&tool)))?
|
||||
}
|
||||
Some(crate::ExtendTo::Face(t)) => {
|
||||
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
|
||||
let face = crate::appearance::face_index_at(tb, t.point)
|
||||
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the target point", t.body)))?;
|
||||
let tool = kernel::SplitTool::Face { body: &tb.body, face };
|
||||
kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::UpTo(&tool)))?
|
||||
}
|
||||
};
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, out, mb.feature);
|
||||
}
|
||||
@@ -234,6 +254,52 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
st.bodies.push(ModelBody::traced_new(name, made, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceRuled { body, edges, distance, mode, angle, direction, flip, corners, face } => {
|
||||
let d = val(vals, distance, Kind::Length)?;
|
||||
let mode = match mode {
|
||||
RuledMode::Normal => kernel::RuledMode::Normal,
|
||||
RuledMode::Tangent => kernel::RuledMode::Tangent,
|
||||
RuledMode::Angle => {
|
||||
let a = angle.as_ref().ok_or_else(|| DocError::Invalid("a ruled surface at an angle needs `angle`".into()))?;
|
||||
kernel::RuledMode::Angle { angle: val(vals, a, Kind::Angle)?, reference: *direction }
|
||||
}
|
||||
RuledMode::Direction => kernel::RuledMode::Direction(
|
||||
direction.ok_or_else(|| DocError::Invalid("a ruled surface along a direction needs `direction`".into()))?,
|
||||
),
|
||||
};
|
||||
let corners = match corners {
|
||||
RuledCorners::Mitre => kernel::RuledCorners::Mitre,
|
||||
RuledCorners::Round => kernel::RuledCorners::Round,
|
||||
};
|
||||
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let r = kernel::Ruled { distance: d, mode, flip: *flip, corners, face: *face };
|
||||
let made = Traced::step(kernel::ruled_surface(&mb.body, edges, &r)?, &[&mb.traced()]);
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::traced_new(name, made, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceUntrim { face, holes, outer, in_place } => {
|
||||
let i = st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", face.body)))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let fi = crate::appearance::face_index_at(&mb, face.point)
|
||||
.ok_or_else(|| DocError::Invalid(format!("Untrim: no face of {} at {:?}", mb.name, face.point)))?;
|
||||
let which = match holes {
|
||||
None => kernel::UntrimHoles::All,
|
||||
Some(points) => kernel::UntrimHoles::At(points),
|
||||
};
|
||||
// A surface body's face is untrimmed where it is when asked; a solid's is copied out.
|
||||
let replace = *in_place && mb.body.is_surface();
|
||||
let made = mb.traced().then(kernel::untrim(&mb.body, fi, which, *outer, replace)?);
|
||||
if replace {
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::traced_new(mb.name, made, mb.feature);
|
||||
}
|
||||
} else {
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::traced_new(name, made, f.id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(DocError::Invalid("not a surface feature".into())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
//! The geometry a feature holds, each piece with its role: the points that pick faces (moved
|
||||
//! with the feature, and the source of its persistent face names), other positions, directions,
|
||||
//! planes, construction references, a primitive's placement point and its profile selections.
|
||||
//! One walk, written once for `&` and `&mut`, behind `transform_geometry`, `face_points`,
|
||||
//! `profile_sels` and `point_expr`. Every variant is listed, so a new one has to say what each of
|
||||
//! its points is.
|
||||
//! The walk (`crate::walk`) is behind `transform_geometry`, `face_points`, `profile_sels` and
|
||||
//! `point_expr`.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::construct::GeoRef;
|
||||
use crate::{AxisRef, FeatureKind, PatternKind, PlaneRef, PointExpr, ProfileSel};
|
||||
use crate::walk::Walk;
|
||||
use crate::{FeatureKind, PointExpr, ProfileSel};
|
||||
|
||||
/// A piece of a feature's geometry: `V` is `&Vec3` or `&mut Vec3`, and so on for the others.
|
||||
pub(crate) enum Geo<V, P, G, E, S> {
|
||||
@@ -30,286 +29,7 @@ pub(crate) enum Geo<V, P, G, E, S> {
|
||||
Profiles(u64, S),
|
||||
}
|
||||
|
||||
pub(crate) type GeoItem<'a> = Geo<&'a Vec3, &'a PlaneRef, &'a GeoRef, &'a PointExpr, &'a ProfileSel>;
|
||||
pub(crate) type GeoItemMut<'a> = Geo<&'a mut Vec3, &'a mut PlaneRef, &'a mut GeoRef, &'a mut PointExpr, &'a mut ProfileSel>;
|
||||
|
||||
/// Every piece of a feature's geometry, written once for `&` and `&mut`: `$item` is `GeoItem` or
|
||||
/// `GeoItemMut`, `$refs` the matching `refs` / `refs_mut`, `$m` is `mut` or nothing. Face picks
|
||||
/// come in the order the feature's face names follow.
|
||||
macro_rules! geometry_walk {
|
||||
($name:ident, $item:ident, $refs:ident $(, $m:tt)?) => {
|
||||
pub(crate) fn $name<'a>(&'a $($m)? self, f: &mut impl FnMut($item<'a>)) {
|
||||
use Geo::{Construct, Dir, Pick, Pinned, Placement, Plane, Pos, Profiles};
|
||||
match self {
|
||||
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Mirror { plane, .. } => f(Plane(plane)),
|
||||
FeatureKind::ConstructionAxis { def } => {
|
||||
for r in def.$refs() {
|
||||
f(Construct(r));
|
||||
}
|
||||
}
|
||||
FeatureKind::ConstructionPoint { def } => {
|
||||
for r in def.$refs() {
|
||||
f(Construct(r));
|
||||
}
|
||||
}
|
||||
FeatureKind::Extrude { sketch, profiles, extent, thin, .. } => {
|
||||
// A thin extrude follows curves; its profiles are not used.
|
||||
if thin.is_none() {
|
||||
f(Profiles(*sketch, profiles));
|
||||
}
|
||||
if let Some(p) = & $($m)? extent.to {
|
||||
f(Pick(p));
|
||||
}
|
||||
if let Some(p) = & $($m)? extent.from {
|
||||
f(Pick(p));
|
||||
}
|
||||
}
|
||||
// Surface Extrude refuses `to` and `from` for now; they would pick faces as for
|
||||
// Extrude.
|
||||
FeatureKind::SurfaceExtrude { sketch, profiles, extent, .. } => {
|
||||
if let Some(s) = profiles {
|
||||
f(Profiles(*sketch, s));
|
||||
}
|
||||
if let Some(p) = & $($m)? extent.to {
|
||||
f(Pick(p));
|
||||
}
|
||||
if let Some(p) = & $($m)? extent.from {
|
||||
f(Pick(p));
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, to, .. } => {
|
||||
f(Profiles(*sketch, profiles));
|
||||
if let AxisRef::Line { origin, dir } = axis {
|
||||
f(Pos(origin));
|
||||
f(Dir(dir));
|
||||
}
|
||||
if let Some(p) = to {
|
||||
f(Pick(p));
|
||||
}
|
||||
}
|
||||
FeatureKind::SurfaceRevolve { sketch, profiles, axis, .. } => {
|
||||
if let Some(s) = profiles {
|
||||
f(Profiles(*sketch, s));
|
||||
}
|
||||
if let AxisRef::Line { origin, dir } = axis {
|
||||
f(Pos(origin));
|
||||
f(Dir(dir));
|
||||
}
|
||||
}
|
||||
FeatureKind::Sweep { sketch, profiles, .. } | FeatureKind::SheetBase { sketch, profiles, .. } => f(Profiles(*sketch, profiles)),
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, .. } => {
|
||||
if let Some(s) = profiles {
|
||||
f(Profiles(*sketch, s));
|
||||
}
|
||||
}
|
||||
FeatureKind::Emboss { sketch, profiles, face, .. } => {
|
||||
f(Profiles(*sketch, profiles));
|
||||
if let Some(face) = face {
|
||||
f(Pinned(& $($m)? face.at));
|
||||
}
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => {
|
||||
// A section that is a point has no profile.
|
||||
for s in sections {
|
||||
if s.point.is_none() {
|
||||
f(Profiles(s.sketch, & $($m)? s.profiles));
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::SurfaceLoft { sections } => {
|
||||
for s in sections {
|
||||
if let Some(p) = & $($m)? s.profiles {
|
||||
f(Profiles(s.sketch, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::Patch { sketch, profiles, edges, .. } => {
|
||||
if let Some(sketch) = sketch {
|
||||
f(Profiles(*sketch, profiles));
|
||||
}
|
||||
for e in edges {
|
||||
f(Pos(e));
|
||||
}
|
||||
}
|
||||
FeatureKind::Fillet { edges, style, .. } => {
|
||||
for e in edges {
|
||||
f(Pos(e));
|
||||
}
|
||||
if let crate::FilletStyle::Variable { start, .. } | crate::FilletStyle::Spline { start, .. } = style {
|
||||
f(Pos(start));
|
||||
}
|
||||
}
|
||||
FeatureKind::Chamfer { edges, .. } | FeatureKind::SheetHem { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => {
|
||||
for e in edges {
|
||||
f(Pos(e));
|
||||
}
|
||||
}
|
||||
FeatureKind::Shell { faces, .. }
|
||||
| FeatureKind::OffsetFace { faces, .. }
|
||||
| FeatureKind::DeleteFace { faces, .. }
|
||||
| FeatureKind::SurfaceOffset { faces, .. } => {
|
||||
for p in faces {
|
||||
f(Pick(p));
|
||||
}
|
||||
}
|
||||
FeatureKind::Unstitch { faces, .. } | FeatureKind::ReverseNormal { faces, .. } => {
|
||||
for p in faces {
|
||||
f(Pos(p));
|
||||
}
|
||||
}
|
||||
FeatureKind::BoundaryFill { cells, .. } => {
|
||||
for p in cells {
|
||||
f(Pos(p));
|
||||
}
|
||||
}
|
||||
FeatureKind::Draft { faces, neutral, pull, .. } => {
|
||||
for p in faces {
|
||||
f(Pick(p));
|
||||
}
|
||||
f(Plane(neutral));
|
||||
f(Dir(pull));
|
||||
}
|
||||
FeatureKind::ReplaceFace { faces, target, .. } => {
|
||||
for p in faces {
|
||||
f(Pick(p));
|
||||
}
|
||||
f(Plane(target));
|
||||
}
|
||||
FeatureKind::SplitFace { faces, plane, tool, .. } => {
|
||||
for p in faces {
|
||||
f(Pick(p));
|
||||
}
|
||||
f(Plane(plane));
|
||||
if let Some(t) = tool {
|
||||
f(Pos(& $($m)? t.point));
|
||||
}
|
||||
}
|
||||
FeatureKind::Split { plane, tool, .. } => {
|
||||
f(Plane(plane));
|
||||
if let Some(t) = tool {
|
||||
f(Pos(& $($m)? t.point));
|
||||
}
|
||||
}
|
||||
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
|
||||
f(Plane(plane));
|
||||
if let Some(t) = tool {
|
||||
f(Pos(& $($m)? t.point));
|
||||
}
|
||||
f(Pos(keep));
|
||||
}
|
||||
FeatureKind::Box { corner: p, .. } | FeatureKind::Sphere { center: p, .. } | FeatureKind::Torus { center: p, .. } => {
|
||||
f(Placement(p))
|
||||
}
|
||||
FeatureKind::Cylinder { base, axis, .. } | FeatureKind::Coil { base, axis, .. } => {
|
||||
f(Placement(base));
|
||||
f(Dir(axis));
|
||||
}
|
||||
FeatureKind::Pattern { pattern, .. } => match pattern {
|
||||
PatternKind::Rectangular { dir1, dir2, .. } => {
|
||||
f(Dir(dir1));
|
||||
if let Some(d) = dir2 {
|
||||
f(Dir(d));
|
||||
}
|
||||
}
|
||||
PatternKind::Circular { origin, axis, .. } => {
|
||||
f(Pos(origin));
|
||||
f(Dir(axis));
|
||||
}
|
||||
// Follows its path sketch.
|
||||
PatternKind::Path { .. } => {}
|
||||
},
|
||||
FeatureKind::Hole { position, direction, points, to, .. } => {
|
||||
// Placed at sketch points, the hole names no face by its position.
|
||||
if points.is_none() {
|
||||
f(Pick(position));
|
||||
} else {
|
||||
f(Pos(position));
|
||||
}
|
||||
f(Dir(direction));
|
||||
if let Some(p) = to {
|
||||
f(Pick(p));
|
||||
}
|
||||
}
|
||||
FeatureKind::Thread { face, .. } => f(Pick(face)),
|
||||
FeatureKind::SheetFlange { edges, .. } => {
|
||||
for e in edges {
|
||||
f(Pos(& $($m)? e.point));
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetFold { a, b, fixed, .. } => {
|
||||
f(Pos(a));
|
||||
f(Pos(b));
|
||||
if let Some(p) = fixed {
|
||||
f(Pos(p));
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetConvert { face, bends, .. } => {
|
||||
// With bend edges the fixed face is found by name (a plate keeps its point).
|
||||
if bends.is_empty() {
|
||||
f(Pos(face));
|
||||
} else {
|
||||
f(Pick(face));
|
||||
}
|
||||
for p in bends {
|
||||
f(Pos(p));
|
||||
}
|
||||
}
|
||||
FeatureKind::Boss { position, direction, .. } => {
|
||||
f(Pick(position));
|
||||
f(Dir(direction));
|
||||
}
|
||||
FeatureKind::SnapFit { position, direction, hook, .. } => {
|
||||
f(Pick(position));
|
||||
f(Dir(direction));
|
||||
f(Dir(hook));
|
||||
}
|
||||
FeatureKind::Rest { position, direction, along, .. } => {
|
||||
f(Pick(position));
|
||||
f(Dir(direction));
|
||||
if let Some(a) = along {
|
||||
f(Dir(a));
|
||||
}
|
||||
}
|
||||
FeatureKind::Lip { face, .. } => f(Pos(face)),
|
||||
FeatureKind::Align { from, to, from_normal, to_normal, .. } => {
|
||||
f(Pos(from));
|
||||
f(Pos(to));
|
||||
if let Some(n) = from_normal {
|
||||
f(Dir(n));
|
||||
}
|
||||
if let Some(n) = to_normal {
|
||||
f(Dir(n));
|
||||
}
|
||||
}
|
||||
FeatureKind::Scale { origin, .. } => f(Pos(origin)),
|
||||
FeatureKind::Move { rotate_axis, .. } => {
|
||||
if let Some(a) = rotate_axis {
|
||||
f(Dir(a));
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetContour { .. }
|
||||
| FeatureKind::SheetPerforate { .. }
|
||||
| FeatureKind::SheetUnfold { .. }
|
||||
| FeatureKind::Combine { .. }
|
||||
| FeatureKind::BoundingSolid { .. }
|
||||
| FeatureKind::Pipe { .. }
|
||||
| FeatureKind::Rib { .. }
|
||||
| FeatureKind::Remove { .. }
|
||||
| FeatureKind::Stitch { .. }
|
||||
| FeatureKind::Thicken { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. }
|
||||
| FeatureKind::MeshConvert { .. }
|
||||
| FeatureKind::BodyTransfer { .. } => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
geometry_walk!(walk_geometry, GeoItem, refs);
|
||||
geometry_walk!(walk_geometry_mut, GeoItemMut, refs_mut, mut);
|
||||
|
||||
/// The points that pick faces (shell, draft, offset and replace faces, a hole's placement and
|
||||
/// `to`, an extrude's `to` and `from`…), in the order of the feature's persistent face names.
|
||||
pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! References from a feature to the rest of the design: the sketches it is built from (by id),
|
||||
//! the features it names (patterned and mirrored features, the construction plane a plane is
|
||||
//! built on) and the bodies it works on (by name). One walk, written once for `&` and `&mut`,
|
||||
//! behind `input_sketches`, `remap_refs` and `remap_bodies`. Every variant is listed, so a new
|
||||
//! one has to say what it refers to.
|
||||
//! built on) and the bodies it works on (by name). The walk (`crate::walk`) is behind
|
||||
//! `input_sketches`, `remap_refs` and `remap_bodies`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{FeatureKind, PatternKind, PlaneRef};
|
||||
use crate::FeatureKind;
|
||||
use crate::walk::Walk;
|
||||
|
||||
/// A reference a feature holds: `S` is `&u64` or `&mut u64` (a sketch id), `N` is `&String` or
|
||||
/// `&mut String` (a feature or body name).
|
||||
@@ -16,219 +16,7 @@ pub(crate) enum Ref<S, N> {
|
||||
Body(N),
|
||||
}
|
||||
|
||||
/// The construction plane a plane is built on (through offsets and angles), for `&` and `&mut`
|
||||
/// (`$m` is `mut` or nothing). Nesting is capped like every walk over a plane.
|
||||
macro_rules! plane_walk {
|
||||
($name:ident $(, $m:tt)?) => {
|
||||
fn $name<'a>(mut p: &'a $($m)? PlaneRef, f: &mut impl FnMut(Ref<&'a $($m)? u64, &'a $($m)? String>)) {
|
||||
for _ in 0..64 {
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
|
||||
PlaneRef::Construction { name } => return f(Ref::Feature(name)),
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
plane_walk!(plane_refs);
|
||||
plane_walk!(plane_refs_mut, mut);
|
||||
|
||||
/// Every reference of a feature, written once for `&` and `&mut`: `$plane` is the matching plane
|
||||
/// walk, `$m` is `mut` or nothing.
|
||||
macro_rules! feature_walk {
|
||||
($name:ident, $plane:ident $(, $m:tt)?) => {
|
||||
pub(crate) fn $name<'a>(&'a $($m)? self, f: &mut impl FnMut(Ref<&'a $($m)? u64, &'a $($m)? String>)) {
|
||||
use Ref::{Body as B, Feature as F, Sketch as S};
|
||||
match self {
|
||||
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } => $plane(plane, f),
|
||||
FeatureKind::Extrude { sketch, targets, .. } | FeatureKind::Revolve { sketch, targets, .. } => {
|
||||
f(S(sketch));
|
||||
for b in targets {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::Emboss { sketch, targets, face, .. } => {
|
||||
f(S(sketch));
|
||||
if let Some(face) = face {
|
||||
f(B(& $($m)? face.body));
|
||||
}
|
||||
for b in targets {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::SurfaceExtrude { sketch, .. }
|
||||
| FeatureKind::SurfaceRevolve { sketch, .. }
|
||||
| FeatureKind::Rib { sketch, .. }
|
||||
| FeatureKind::SheetBase { sketch, .. }
|
||||
| FeatureKind::SheetContour { sketch, .. } => f(S(sketch)),
|
||||
FeatureKind::SurfaceSweep { sketch, path_sketch, .. } => {
|
||||
f(S(sketch));
|
||||
f(S(path_sketch));
|
||||
}
|
||||
FeatureKind::SurfaceLoft { sections } => {
|
||||
for s in sections {
|
||||
f(S(& $($m)? s.sketch));
|
||||
}
|
||||
}
|
||||
FeatureKind::Sweep { sketch, path_sketch, targets, .. } => {
|
||||
f(S(sketch));
|
||||
f(S(path_sketch));
|
||||
for b in targets {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::Pipe { path_sketch, targets, .. } => {
|
||||
f(S(path_sketch));
|
||||
for b in targets {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::Loft { sections, targets, .. } => {
|
||||
for s in sections {
|
||||
f(S(& $($m)? s.sketch));
|
||||
}
|
||||
for b in targets {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::Coil { targets, .. } => {
|
||||
for b in targets {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::Hole { points, .. } => {
|
||||
if let Some(p) = points {
|
||||
f(S(& $($m)? p.sketch));
|
||||
}
|
||||
}
|
||||
FeatureKind::Pattern { features, pattern, bodies } => {
|
||||
for n in features {
|
||||
f(F(n));
|
||||
}
|
||||
if let PatternKind::Path { path_sketch, .. } = pattern {
|
||||
f(S(path_sketch));
|
||||
}
|
||||
for b in bodies {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::Mirror { features, plane, bodies, .. } => {
|
||||
for n in features {
|
||||
f(F(n));
|
||||
}
|
||||
$plane(plane, f);
|
||||
for b in bodies {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::Split { body, plane, tool } | FeatureKind::SurfaceTrim { body, plane, tool, .. } => {
|
||||
f(B(body));
|
||||
$plane(plane, f);
|
||||
if let Some(t) = tool {
|
||||
f(B(& $($m)? t.body));
|
||||
}
|
||||
}
|
||||
FeatureKind::SplitFace { body, plane, tool, sketch, .. } => {
|
||||
$plane(plane, f);
|
||||
if let Some(s) = sketch {
|
||||
f(S(s));
|
||||
}
|
||||
if let Some(b) = body {
|
||||
f(B(b));
|
||||
}
|
||||
if let Some(t) = tool {
|
||||
f(B(& $($m)? t.body));
|
||||
}
|
||||
}
|
||||
FeatureKind::Draft { neutral: plane, body, .. } | FeatureKind::ReplaceFace { target: plane, body, .. } => {
|
||||
$plane(plane, f);
|
||||
if let Some(b) = body {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetFold { sketch, body, .. } | FeatureKind::Patch { sketch, body, .. } => {
|
||||
if let Some(s) = sketch {
|
||||
f(S(s));
|
||||
}
|
||||
if let Some(b) = body {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::Fillet { body, .. }
|
||||
| FeatureKind::Chamfer { body, .. }
|
||||
| FeatureKind::Shell { body, .. }
|
||||
| FeatureKind::OffsetFace { body, .. }
|
||||
| FeatureKind::DeleteFace { body, .. }
|
||||
| FeatureKind::SheetFlange { body, .. }
|
||||
| FeatureKind::SheetHem { body, .. }
|
||||
| FeatureKind::SheetPerforate { body, .. }
|
||||
| FeatureKind::SheetUnfold { body, .. }
|
||||
| FeatureKind::Boss { body, .. }
|
||||
| FeatureKind::Lip { body, .. }
|
||||
| FeatureKind::SnapFit { body, .. }
|
||||
| FeatureKind::Rest { body, .. }
|
||||
| FeatureKind::SurfaceOffset { body, .. } => {
|
||||
if let Some(b) = body {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetConvert { body, .. }
|
||||
| FeatureKind::SurfaceExtend { body, .. }
|
||||
| FeatureKind::Unstitch { body, .. }
|
||||
| FeatureKind::ReverseNormal { body, .. }
|
||||
| FeatureKind::MeshConvert { body, .. } => f(B(body)),
|
||||
FeatureKind::Combine { target, tools, .. } => {
|
||||
f(B(target));
|
||||
for b in tools {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::Thicken { bodies, targets, .. } => {
|
||||
for b in bodies {
|
||||
f(B(b));
|
||||
}
|
||||
for b in targets {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::BodyTransfer { bodies, .. }
|
||||
| FeatureKind::Scale { bodies, .. }
|
||||
| FeatureKind::BoundingSolid { bodies, .. }
|
||||
| FeatureKind::Align { bodies, .. }
|
||||
| FeatureKind::Remove { bodies }
|
||||
| FeatureKind::Stitch { bodies, .. }
|
||||
| FeatureKind::Move { bodies, .. } => {
|
||||
for b in bodies {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
FeatureKind::BoundaryFill { tools, .. } => {
|
||||
for b in tools {
|
||||
f(B(b));
|
||||
}
|
||||
}
|
||||
// Construction axes and points refer to geometry, which copies remap on their own
|
||||
// (`Feature::remap_clone`).
|
||||
FeatureKind::ConstructionAxis { .. }
|
||||
| FeatureKind::ConstructionPoint { .. }
|
||||
| FeatureKind::Box { .. }
|
||||
| FeatureKind::Cylinder { .. }
|
||||
| FeatureKind::Sphere { .. }
|
||||
| FeatureKind::Torus { .. }
|
||||
| FeatureKind::Thread { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. } => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
feature_walk!(walk_refs, plane_refs);
|
||||
feature_walk!(walk_refs_mut, plane_refs_mut, mut);
|
||||
|
||||
/// Sketches the feature is built from (profiles, paths, points): deleting one of them
|
||||
/// deletes the feature.
|
||||
pub fn input_sketches(&self) -> Vec<u64> {
|
||||
|
||||
@@ -141,7 +141,7 @@ const GOLDEN_REFS: &str = include_str!("feature_walk_tests/golden_refs.txt");
|
||||
|
||||
#[test]
|
||||
fn input_sketches_lists_every_variant_with_a_sketch_field() {
|
||||
let src = include_str!("document.rs");
|
||||
let src = include_str!("document.rs").replace("\r\n", "\n");
|
||||
let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap();
|
||||
let mut with_sketch = Vec::new();
|
||||
let mut variant = String::new();
|
||||
|
||||
@@ -151,7 +151,21 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
|
||||
("unstitch", json!({"type": "unstitch", "body": "B"})),
|
||||
("reverse_normal", json!({"type": "reverse_normal", "body": "B"})),
|
||||
("surface_untrim", json!({"type": "surface_untrim", "face": {"body": "B", "point": o}, "holes": [o], "outer": true})),
|
||||
("surface_extend", json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance"})),
|
||||
(
|
||||
"surface_extend_to_plane",
|
||||
json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance", "mode": "curvature", "up_to": {"plane": plane("extend")}}),
|
||||
),
|
||||
(
|
||||
"surface_extend_to_face",
|
||||
json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance", "up_to": {"face": {"body": "B", "point": o}}, "neighbours": false}),
|
||||
),
|
||||
(
|
||||
"surface_ruled",
|
||||
json!({"type": "surface_ruled", "body": "B", "edges": [o], "distance": "ruled.distance", "mode": "angle", "angle": "ruled.angle",
|
||||
"direction": [0, 0, 1], "face": [1, 0, 0]}),
|
||||
),
|
||||
("surface_trim", json!({"type": "surface_trim", "body": "B", "plane": plane("trim"), "keep": o})),
|
||||
("move", json!({"type": "move", "bodies": ["B"], "translate": ["move.x", "move.y", "move.z"], "angle": "move.angle"})),
|
||||
("import", json!({"type": "import", "file": "a.step", "step": ""})),
|
||||
|
||||
@@ -325,10 +325,26 @@ surface_offset
|
||||
expressions: soff.distance, soff.tolerance
|
||||
unstitch
|
||||
reverse_normal
|
||||
surface_untrim
|
||||
surface_extend
|
||||
inputs:
|
||||
Distance = extend.distance (Length)
|
||||
expressions: extend.distance
|
||||
surface_extend_to_plane
|
||||
inputs:
|
||||
Distance = extend.distance (Length)
|
||||
Angle = extend.angle (Angle)
|
||||
Offset = extend.offset (Length)
|
||||
expressions: extend.distance, extend.angle, extend.offset
|
||||
surface_extend_to_face
|
||||
inputs:
|
||||
Distance = extend.distance (Length)
|
||||
expressions: extend.distance
|
||||
surface_ruled
|
||||
inputs:
|
||||
Distance = ruled.distance (Length)
|
||||
Angle = ruled.angle (Angle)
|
||||
expressions: ruled.distance, ruled.angle
|
||||
surface_trim
|
||||
inputs:
|
||||
Angle = trim.angle (Angle)
|
||||
|
||||
@@ -150,8 +150,18 @@ unstitch
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
reverse_normal
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
surface_untrim
|
||||
moves: face.point[0], face.point[1], face.point[2], holes[0][0], holes[0][1], holes[0][2]
|
||||
face points: [0, 0, 0]
|
||||
surface_extend
|
||||
moves: edges[0][0], edges[0][1], edges[0][2]
|
||||
surface_extend_to_plane
|
||||
moves: edges[0][0], edges[0][1], edges[0][2], up_to.plane.axis_dir[0], up_to.plane.axis_dir[1], up_to.plane.axis_origin[0], up_to.plane.axis_origin[1], up_to.plane.axis_origin[2]
|
||||
surface_extend_to_face
|
||||
moves: edges[0][0], edges[0][1], edges[0][2], up_to.face.point[0], up_to.face.point[1], up_to.face.point[2]
|
||||
surface_ruled
|
||||
moves: direction[0], direction[2], edges[0][0], edges[0][1], edges[0][2], face[1], face[2]
|
||||
face points: [1, 0, 0]
|
||||
surface_trim
|
||||
moves: keep[0], keep[1], keep[2], plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2]
|
||||
move
|
||||
|
||||
@@ -168,8 +168,17 @@ unstitch
|
||||
remap_bodies: body
|
||||
reverse_normal
|
||||
remap_bodies: body
|
||||
surface_untrim
|
||||
remap_bodies: face.body
|
||||
surface_extend
|
||||
remap_bodies: body
|
||||
surface_extend_to_plane
|
||||
remap_refs: up_to.plane.base.base.name
|
||||
remap_bodies: body
|
||||
surface_extend_to_face
|
||||
remap_bodies: body, up_to.face.body
|
||||
surface_ruled
|
||||
remap_bodies: body
|
||||
surface_trim
|
||||
remap_refs: plane.base.base.name
|
||||
remap_bodies: body, tool.body
|
||||
|
||||
@@ -153,13 +153,51 @@
|
||||
//! The saved visibility can also list hidden assembly joints (`visibility.hidden_joints`,
|
||||
//! joint ids): their glyphs aren't drawn, but they still constrain and drive the parts. A
|
||||
//! design without it shows every joint, as it always did; 0.10.0 would show them all.
|
||||
//! - 21: named render setups (`render: {setups: {name: setup}}`): renderer-independent render
|
||||
//! intent (camera, resolution, exposure, environment). Version 20 designs need no
|
||||
//! transformation (an absent field means no setups, and empty render state is not saved); the
|
||||
//! version stamp makes 0.11.0 (which reads up to 20) ask for an update instead of silently
|
||||
//! dropping saved setups.
|
||||
//! Also first saved in 21, a design can carry `metadata` (#1015): design properties
|
||||
//! (`project`, `part_number`, `revision`, `author`, `organization`, `description`, `license`,
|
||||
//! each text) and other tools' data, each tool's under its own reverse-DNS key
|
||||
//! (`com.example.tool`). Every key is kept as it is, and nothing in it is checked when the
|
||||
//! design opens. A design without it saves as before. 0.11.x refuses format 21, so no
|
||||
//! released build drops the properties when it saves.
|
||||
//! Under the same version, looks can explicitly select opaque PBR parameters (`pbr`: metal
|
||||
//! fraction, roughness, dielectric IOR). Absent fields keep the scalar shading a look always
|
||||
//! had; 0.11.0 asks for an update instead of silently dropping the response.
|
||||
//! A `SurfaceUntrim` feature (Untrim: a `face` as `body` and `point`, optional `holes`
|
||||
//! points, `outer` and `in_place`) is also first saved in 21; 0.11.0 asks for an update
|
||||
//! instead of meeting a feature it can't read.
|
||||
//! A `SurfaceRuled` feature (Ruled Surface: `body`, `edges` points, `distance`, optional
|
||||
//! `mode`, `angle`, `direction`, `flip`, `corners` and `face`) is also first saved in 21;
|
||||
//! 0.11.0 asks for an update instead of meeting a feature it can't read.
|
||||
//! Under the same version, a `SurfaceExtend` can say how it continues the surface (`mode`:
|
||||
//! `tangent` or `curvature`; `natural` when absent), extend up to something instead of its
|
||||
//! `distance` (`up_to`: a `plane`, a `face` as `body` and `point`, or `bounds`) and keep the
|
||||
//! neighbouring edges as they are (`neighbours: false`). An Extend without them builds the
|
||||
//! planar extension it always did and saves as before; 0.11.0 asks for an update instead of
|
||||
//! dropping them and extending a plane by the distance.
|
||||
//! Under the same version, an extrude's extent records how a symmetric `distance` is read
|
||||
//! (`convention`: `total_distance`, split evenly around the start plane, which new solid
|
||||
//! extrudes save; or `legacy_per_side`). An extent without it keeps the per-side reading
|
||||
//! designs always had, so their thickness and parameters don't change; 0.11.0 asks for an
|
||||
//! update instead of doubling a total-distance extrude.
|
||||
//! Under the same version, a Convert to Sheet Metal bend point can lie in the middle of a
|
||||
//! chamfer (a flat strip between two faces), which then is one bend at the two faces' virtual
|
||||
//! sharp; 0.11.0 bent the chamfer's nearest edge instead or found no edge there. Bend points
|
||||
//! on edges convert as before.
|
||||
//! A bend point can also lie on a bend modelled with its radius (a cylinder between two flat
|
||||
//! faces tangent to it), which then is one bend at the two faces' virtual sharp with the
|
||||
//! rule's radius; 0.11.0 found no edge there or refused the curved face.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 20;
|
||||
pub const FORMAT: u32 = 21;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solidcraft/{n}")
|
||||
@@ -176,7 +214,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; 19] = [
|
||||
const JSON_STEPS: [JsonStep; 20] = [
|
||||
|_| Ok(()),
|
||||
v2_to_v3,
|
||||
v3_to_v4,
|
||||
@@ -196,6 +234,7 @@ const JSON_STEPS: [JsonStep; 19] = [
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
];
|
||||
|
||||
/// Stored face and edge names were worked out from geometry before format 14; they get the
|
||||
@@ -394,7 +433,7 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
"this design was saved by a newer SolidCraft (file format {version}; this version reads formats up to {FORMAT}): update SolidCraft to open it"
|
||||
)));
|
||||
}
|
||||
check_numbers(&v, 0)?;
|
||||
check_design_numbers(&v)?;
|
||||
crate::config::validate_rules_json(&v)?;
|
||||
crate::config::validate_styles_json(&v)?;
|
||||
crate::face_anchor::validate_face_anchors_json(&v)?;
|
||||
@@ -863,6 +902,16 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> {
|
||||
/// outside what the kernel's tolerances serve.
|
||||
const MAX_REAL: f64 = 1e9;
|
||||
|
||||
/// Every number the design itself holds is in range ([`check_numbers`]). `metadata` is left
|
||||
/// alone: it is other tools' data, kept as it is and never read as a number (#1015); parsing
|
||||
/// already bounds its nesting.
|
||||
fn check_design_numbers(v: &Value) -> Result<()> {
|
||||
match v {
|
||||
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != "metadata").try_for_each(|(_, x)| check_numbers(x, 1)),
|
||||
v => check_numbers(v, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every non-integer number in the file is finite and within ±`MAX_REAL` (a damaged or hostile
|
||||
/// file is refused instead of overflowing the geometry later).
|
||||
fn check_numbers(v: &Value, depth: usize) -> Result<()> {
|
||||
@@ -1094,6 +1143,7 @@ mod tests {
|
||||
sketch,
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "10".into(),
|
||||
direction: Direction::Negative,
|
||||
distance2: None,
|
||||
|
||||
@@ -427,6 +427,7 @@ mod tests {
|
||||
sketch: if missing_sketch { 999_999 } else { id },
|
||||
profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] },
|
||||
extent: crate::Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
|
||||
+318
-84
@@ -349,22 +349,40 @@ fn mismatch(a: &Mat, b: &Mat) -> (f64, f64) {
|
||||
(t, sin2.atan2(c(0, 0) + c(1, 1) + c(2, 2) - 1.0))
|
||||
}
|
||||
|
||||
/// Passes over the motion links: a chain of this many links carries a value through in any order.
|
||||
const MAX_LINK_HOPS: usize = 4;
|
||||
/// Passes over the motion links at most: a chain of up to this many links carries its value
|
||||
/// through, whatever order its links are stored in.
|
||||
const MAX_LINK_PASSES: usize = 64;
|
||||
|
||||
/// Motion links in order (a few passes for chains), then the linked values' limits.
|
||||
/// Motion links, pass after pass until nothing changes (a chain stored from its end takes a pass
|
||||
/// per link), each linked value within its joint's limits as it is set. The result is the same
|
||||
/// whatever the values were before and however the links are stored, so applying them again
|
||||
/// changes nothing (links that form a cycle stop after `MAX_LINK_PASSES`).
|
||||
fn apply_links(asm: &Assembly, vals: &mut Values) {
|
||||
for _ in 0..MAX_LINK_HOPS {
|
||||
let limits: BTreeMap<(u64, usize), (f64, f64)> = asm
|
||||
.links
|
||||
.iter()
|
||||
.filter_map(|l| {
|
||||
let (lo, hi) = asm.joints.iter().find(|j| j.id == l.b)?.limits.get(l.ib).copied().flatten()?;
|
||||
Some(((l.b, l.ib), (lo.min(hi), hi.max(lo))))
|
||||
})
|
||||
.collect();
|
||||
// The linked values, to see whether a pass changed any (several links writing one value
|
||||
// change it within a pass, but settle from one pass to the next).
|
||||
let linked = |vals: &Values| -> Vec<Option<f64>> { asm.links.iter().map(|l| vals.get(&l.b).and_then(|q| q.get(l.ib)).copied()).collect() };
|
||||
for _ in 0..MAX_LINK_PASSES {
|
||||
let before = linked(vals);
|
||||
for l in &asm.links {
|
||||
let Some(src) = vals.get(&l.a).and_then(|q| q.get(l.ia)).copied() else { continue };
|
||||
let mut next = l.ratio * src + l.offset;
|
||||
if let Some((lo, hi)) = limits.get(&(l.b, l.ib)) {
|
||||
next = next.max(*lo).min(*hi);
|
||||
}
|
||||
if let Some(v) = vals.get_mut(&l.b).and_then(|q| q.get_mut(l.ib)) {
|
||||
*v = l.ratio * src + l.offset;
|
||||
*v = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in &asm.links {
|
||||
if let (Some(j), Some(q)) = (asm.joints.iter().find(|j| j.id == l.b), vals.get_mut(&l.b)) {
|
||||
clamp(j, q);
|
||||
if linked(vals) == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -481,7 +499,7 @@ fn link_chains(asm: &Assembly, tree: &Tree, around: &BTreeSet<u64>, linked: &BTr
|
||||
let mut frontier: BTreeSet<(u64, usize)> =
|
||||
around.iter().filter_map(|id| tree.joints.get(id)).flat_map(|j| (0..j.kind.dofs().len()).map(|i| (j.id, i))).collect();
|
||||
let mut seen = frontier.clone();
|
||||
for _ in 0..MAX_LINK_HOPS {
|
||||
for _ in 0..MAX_LINK_PASSES {
|
||||
let next: BTreeSet<(u64, usize)> =
|
||||
asm.links.iter().filter(|l| frontier.contains(&(l.b, l.ib))).map(|l| (l.a, l.ia)).filter(|v| !seen.contains(v)).collect();
|
||||
if next.is_empty() {
|
||||
@@ -526,35 +544,7 @@ fn close_loops(tree: &Tree, closing: &[u64], asm: &Assembly, held: &Held, vals:
|
||||
return (Values::new(), stuck);
|
||||
}
|
||||
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
|
||||
// Each loop: its closing joint and the joints on the tree paths between its sides; loops that
|
||||
// share a joint (or a value feeding them through links) are solved together.
|
||||
let mut groups: Vec<(Vec<u64>, BTreeSet<u64>, BTreeSet<(u64, usize)>)> = Vec::new();
|
||||
for id in closing {
|
||||
let Some(j) = tree.joints.get(id) else { continue };
|
||||
// A joint between an occurrence and itself (only a hand-edited file has one) is only checked.
|
||||
if j.a.occurrence == j.b.occurrence {
|
||||
continue;
|
||||
}
|
||||
let side = |occ: u64| -> BTreeSet<u64> { tree.path(occ).1.iter().map(|(k, _)| *k).collect() };
|
||||
let mut around: BTreeSet<u64> = side(j.a.occurrence).symmetric_difference(&side(j.b.occurrence)).copied().collect();
|
||||
around.insert(*id);
|
||||
// The values the loop depends on: its joints' and those its links read. Loops tie together
|
||||
// through a shared value only (a rigid joint on both paths has none; two values of one
|
||||
// joint can feed two independent loops).
|
||||
let mut touches = link_chains(asm, tree, &around, &linked).0;
|
||||
touches.extend(around.iter().filter_map(|id| tree.joints.get(id)).flat_map(|j| (0..j.kind.dofs().len()).map(|i| (j.id, i))));
|
||||
let mut members = vec![*id];
|
||||
groups.retain(|(c, a, t)| {
|
||||
if t.is_disjoint(&touches) {
|
||||
return true;
|
||||
}
|
||||
members.extend(c.iter().copied());
|
||||
around.extend(a.iter().copied());
|
||||
touches.extend(t.iter().copied());
|
||||
false
|
||||
});
|
||||
groups.push((members, around, touches));
|
||||
}
|
||||
let groups = loop_groups(tree, closing, asm, &linked);
|
||||
let spent = std::cell::Cell::new(0usize);
|
||||
for (group, around, touches) in &groups {
|
||||
let moves = held_moves(asm, held, touches, vals);
|
||||
@@ -581,6 +571,225 @@ fn close_loops(tree: &Tree, closing: &[u64], asm: &Assembly, held: &Held, vals:
|
||||
(moved, stuck)
|
||||
}
|
||||
|
||||
/// A group of loops solved together: its closing joints, the joints around them, and the values
|
||||
/// it touches.
|
||||
type Group = (Vec<u64>, BTreeSet<u64>, BTreeSet<(u64, usize)>);
|
||||
|
||||
/// The loops' groups. Each loop: its closing joint and the joints on the tree paths between its
|
||||
/// sides; loops that share a value (of a joint, or one feeding them through links) are solved
|
||||
/// together.
|
||||
fn loop_groups(tree: &Tree, closing: &[u64], asm: &Assembly, linked: &BTreeSet<(u64, usize)>) -> Vec<Group> {
|
||||
let mut groups: Vec<Group> = Vec::new();
|
||||
for id in closing {
|
||||
let Some(j) = tree.joints.get(id) else { continue };
|
||||
// A joint between an occurrence and itself (only a hand-edited file has one) is only checked.
|
||||
if j.a.occurrence == j.b.occurrence {
|
||||
continue;
|
||||
}
|
||||
let side = |occ: u64| -> BTreeSet<u64> { tree.path(occ).1.iter().map(|(k, _)| *k).collect() };
|
||||
let mut around: BTreeSet<u64> = side(j.a.occurrence).symmetric_difference(&side(j.b.occurrence)).copied().collect();
|
||||
around.insert(*id);
|
||||
// The values the loop depends on: its joints' and those its links read. Loops tie together
|
||||
// through a shared value only (a rigid joint on both paths has none; two values of one
|
||||
// joint can feed two independent loops).
|
||||
let mut touches = link_chains(asm, tree, &around, linked).0;
|
||||
touches.extend(around.iter().filter_map(|id| tree.joints.get(id)).flat_map(|j| (0..j.kind.dofs().len()).map(|i| (j.id, i))));
|
||||
let mut members = vec![*id];
|
||||
groups.retain(|(c, a, t)| {
|
||||
if t.is_disjoint(&touches) {
|
||||
return true;
|
||||
}
|
||||
members.extend(c.iter().copied());
|
||||
around.extend(a.iter().copied());
|
||||
touches.extend(t.iter().copied());
|
||||
false
|
||||
});
|
||||
groups.push((members, around, touches));
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// The values a solve of the loops `around` moves: every value of their joints but the `held`
|
||||
/// ones and motion-link targets, and the values the targets' link chains start from.
|
||||
fn unknowns(tree: &Tree, around: &BTreeSet<u64>, asm: &Assembly, linked: &BTreeSet<(u64, usize)>, held: &Held) -> Vec<(u64, usize)> {
|
||||
let mut vars: Vec<(u64, usize)> = Vec::new();
|
||||
for j in around.iter().filter_map(|id| tree.joints.get(id)) {
|
||||
vars.extend((0..j.kind.dofs().len()).map(|i| (j.id, i)).filter(|v| !linked.contains(v) && !held.contains_key(v)));
|
||||
}
|
||||
for v in link_chains(asm, tree, around, linked).1 {
|
||||
if !held.contains_key(&v) && !vars.contains(&v) {
|
||||
vars.push(v);
|
||||
}
|
||||
}
|
||||
vars
|
||||
}
|
||||
|
||||
/// The loops' size: the diagonal of the box around their joints' A frames, 1 mm to 1 km. Points
|
||||
/// that far apart weigh turns like moves.
|
||||
fn loop_scale(tree: &Tree, around: &BTreeSet<u64>, vals: &Values) -> f64 {
|
||||
let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN));
|
||||
for id in around {
|
||||
let (Some(j), Some((fa, _))) = (tree.joints.get(id), tree.frames.get(id)) else { continue };
|
||||
let Some(t) = tree.world(j.a.occurrence, vals) else { continue };
|
||||
let p = apply_point(&mat_mul(&t, fa), Vec3::ZERO);
|
||||
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
|
||||
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
|
||||
}
|
||||
if lo.x <= hi.x { (hi - lo).len().clamp(1.0, 1e6) } else { 1.0 }
|
||||
}
|
||||
|
||||
/// How far each closing joint is from meeting: three points of B's joint frame, `scale` apart,
|
||||
/// where the joint wants them against where the tree puts them (appended to `out`).
|
||||
fn closure_gaps(tree: &Tree, closing: &[u64], vals: &Values, scale: f64, out: &mut Vec<f64>) -> Option<()> {
|
||||
for id in closing {
|
||||
let (j, (_, fb)) = (tree.joints.get(id)?, tree.frames.get(id)?);
|
||||
let tb = tree.world(j.b.occurrence, vals)?;
|
||||
let want = tree.step(*id, true, &tree.world(j.a.occurrence, vals)?, vals)?;
|
||||
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
|
||||
let at = apply_point(fb, p);
|
||||
let d = apply_point(&want, at) - apply_point(&tb, at);
|
||||
out.extend([d.x, d.y, d.z]);
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Occurrences whose degrees of freedom are worked out through their loops, at most; the rest
|
||||
/// keep the tree's count.
|
||||
const MAX_DOF_OCCURRENCES: usize = 256;
|
||||
|
||||
/// The degrees of freedom of the occurrences loops constrain: how many independent ways each can
|
||||
/// still move with every loop kept closed, the rank of its placement over the joint values once
|
||||
/// the loops' closure is taken out (rank [J_loops; J_placement] - rank J_loops). An occurrence's
|
||||
/// values are those on its tree path and the unknowns of the loops on that path. The tree's
|
||||
/// count stands where a loop group is over the solver's caps or the work runs out.
|
||||
fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &mut BTreeMap<u64, usize>) {
|
||||
if closing.len() > MAX_LOOPS {
|
||||
return;
|
||||
}
|
||||
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
|
||||
let groups = loop_groups(tree, closing, asm, &linked);
|
||||
// The joints loops depend on: those around them, those whose values they read, and those a
|
||||
// motion link carries any of these values into.
|
||||
let mut relevant: BTreeSet<u64> = groups.iter().flat_map(|(_, a, t)| a.iter().copied().chain(t.iter().map(|(j, _)| *j))).collect();
|
||||
for _ in 0..MAX_LINK_PASSES {
|
||||
let more: Vec<u64> = asm.links.iter().filter(|l| relevant.contains(&l.a) && !relevant.contains(&l.b)).map(|l| l.b).collect();
|
||||
if more.is_empty() {
|
||||
break;
|
||||
}
|
||||
relevant.extend(more);
|
||||
}
|
||||
// The occurrences a loop may hold or move: placed by such a joint, or below one that is
|
||||
// (worked out down the tree, parents first). The others keep the tree's count.
|
||||
let mut candidates: BTreeSet<u64> = BTreeSet::new();
|
||||
for occ in &tree.order {
|
||||
if let Some(&(from, joint, _)) = tree.parent.get(occ)
|
||||
&& (relevant.contains(&joint) || candidates.contains(&from))
|
||||
{
|
||||
candidates.insert(*occ);
|
||||
}
|
||||
}
|
||||
let (mut spent, mut counted) = (0usize, 0usize);
|
||||
for occ in tree.order.iter().filter(|o| candidates.contains(o)) {
|
||||
let path: Vec<u64> = tree.path(*occ).1.iter().map(|(k, _)| *k).collect();
|
||||
let (mut values, mut have): (Vec<(u64, usize)>, BTreeSet<(u64, usize)>) = (Vec::new(), BTreeSet::new());
|
||||
let mut add = |values: &mut Vec<(u64, usize)>, v: (u64, usize)| {
|
||||
if have.insert(v) {
|
||||
values.push(v);
|
||||
}
|
||||
};
|
||||
for j in path.iter().filter_map(|k| tree.joints.get(k)) {
|
||||
for v in (0..j.kind.dofs().len()).map(|i| (j.id, i)).filter(|v| !linked.contains(v)) {
|
||||
add(&mut values, v);
|
||||
}
|
||||
if values.len() > MAX_LOOP_VALUES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if values.len() > MAX_LOOP_VALUES {
|
||||
continue;
|
||||
}
|
||||
// A value on the path a motion link sets moves with the values its link chain starts from.
|
||||
let on_path: BTreeSet<u64> = path.iter().copied().collect();
|
||||
for v in link_chains(asm, tree, &on_path, &linked).1 {
|
||||
add(&mut values, v);
|
||||
}
|
||||
// The loops on the path, and every loop whose values those reach (a linked source may be
|
||||
// held by a loop of its own), each once.
|
||||
let (mut closers, mut around) = (Vec::new(), BTreeSet::new());
|
||||
let mut taken: BTreeSet<usize> = BTreeSet::new();
|
||||
loop {
|
||||
let more: Vec<usize> = groups
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(g, _)| !taken.contains(g))
|
||||
.filter(|(_, (_, a, t))| path.iter().any(|k| a.contains(k)) || t.iter().any(|v| values.contains(v)))
|
||||
.map(|(g, _)| g)
|
||||
.collect();
|
||||
if more.is_empty() {
|
||||
break;
|
||||
}
|
||||
for (c, a, _) in more.iter().filter_map(|g| groups.get(*g)) {
|
||||
closers.extend(c.iter().copied());
|
||||
around.extend(a.iter().copied());
|
||||
for v in unknowns(tree, a, asm, &linked, &Held::new()) {
|
||||
add(&mut values, v);
|
||||
}
|
||||
}
|
||||
taken.extend(more);
|
||||
}
|
||||
// No loop reaches this occurrence's values (not even through a motion link it drives):
|
||||
// the tree's count stands.
|
||||
if taken.is_empty() {
|
||||
continue;
|
||||
}
|
||||
counted += 1;
|
||||
if counted > MAX_DOF_OCCURRENCES || spent > MAX_LOOP_WORK {
|
||||
return;
|
||||
}
|
||||
if values.len() > MAX_LOOP_VALUES {
|
||||
continue;
|
||||
}
|
||||
let scale = loop_scale(tree, &around, vals);
|
||||
let mut work = vals.clone();
|
||||
// The loops' gaps, then three points of the occurrence `scale` apart.
|
||||
let mut f = |x: &[f64]| -> Option<Vec<f64>> {
|
||||
spent += 1;
|
||||
for ((id, i), v) in values.iter().zip(x) {
|
||||
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
apply_links(asm, &mut work);
|
||||
let mut out = Vec::with_capacity(closers.len() * 9 + 9);
|
||||
closure_gaps(tree, &closers, &work, scale, &mut out)?;
|
||||
let t = tree.world(*occ, &work)?;
|
||||
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
|
||||
let q = apply_point(&t, p);
|
||||
out.extend([q.x, q.y, q.z]);
|
||||
}
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
};
|
||||
let x0: Vec<f64> = values.iter().map(|(id, i)| vals.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
|
||||
let (n, gaps) = (values.len(), closers.len() * 9);
|
||||
let Some(jac) = jacobian(&mut f, &x0, gaps + 9) else { continue };
|
||||
let rank = |rows: usize| {
|
||||
let mut a = linalg::Mat::zeros(rows, n);
|
||||
let mut big = 0.0f64;
|
||||
for r in 0..rows {
|
||||
for c in 0..n {
|
||||
let v = jac.get(r * n + c).copied().unwrap_or(0.0);
|
||||
big = big.max(v.abs());
|
||||
a.set(r, c, v);
|
||||
}
|
||||
}
|
||||
linalg::rref(&mut a, 1e-6 * big.max(1.0)).len()
|
||||
};
|
||||
let free = rank(gaps + 9).saturating_sub(rank(gaps));
|
||||
dof.insert(*occ, free.min(6));
|
||||
}
|
||||
}
|
||||
|
||||
/// Close one group of loops: move the values of the joints `around` them (all but the `held`
|
||||
/// values, motion-link targets and rigid joints, within their limits; a linked value moves
|
||||
/// through the start of its link chain) until every loop-closing joint meets. A loop open at the
|
||||
@@ -601,16 +810,7 @@ fn close_group(
|
||||
spent: &std::cell::Cell<usize>,
|
||||
vals: &mut Values,
|
||||
) -> Option<BTreeMap<(u64, usize), f64>> {
|
||||
let starts = link_chains(asm, tree, around, linked).1;
|
||||
let mut vars: Vec<(u64, usize)> = Vec::new();
|
||||
for j in around.iter().filter_map(|id| tree.joints.get(id)) {
|
||||
vars.extend((0..j.kind.dofs().len()).map(|i| (j.id, i)).filter(|v| !linked.contains(v) && !held.contains_key(v)));
|
||||
}
|
||||
for v in starts {
|
||||
if !held.contains_key(&v) && !vars.contains(&v) {
|
||||
vars.push(v);
|
||||
}
|
||||
}
|
||||
let vars = unknowns(tree, around, asm, linked, held);
|
||||
if vars.len() > MAX_LOOP_VALUES {
|
||||
return Some(BTreeMap::new());
|
||||
}
|
||||
@@ -621,16 +821,7 @@ fn close_group(
|
||||
None => (f64::NEG_INFINITY, f64::INFINITY),
|
||||
})
|
||||
.collect();
|
||||
// Turns weigh like moves through points a loop's size apart.
|
||||
let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN));
|
||||
for id in around {
|
||||
let (Some(j), Some((fa, _))) = (tree.joints.get(id), tree.frames.get(id)) else { continue };
|
||||
let Some(t) = tree.world(j.a.occurrence, vals) else { continue };
|
||||
let p = apply_point(&mat_mul(&t, fa), Vec3::ZERO);
|
||||
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
|
||||
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
|
||||
}
|
||||
let scale = if lo.x <= hi.x { (hi - lo).len().clamp(1.0, 1e6) } else { 1.0 };
|
||||
let scale = loop_scale(tree, around, vals);
|
||||
// Closer than the conflict check needs: 1e-7 mm, and turns of 1e-10 rad across the loop.
|
||||
let tol = (1e-10 * scale).min(1e-7);
|
||||
// The values with the held ones `t` of the way along (and the unknowns at `x`).
|
||||
@@ -681,16 +872,7 @@ fn close_group(
|
||||
}
|
||||
place(&mut work, x, t);
|
||||
let mut out = Vec::with_capacity(closing.len() * 9);
|
||||
for id in closing {
|
||||
let (j, (_, fb)) = (tree.joints.get(id)?, tree.frames.get(id)?);
|
||||
let tb = tree.world(j.b.occurrence, &work)?;
|
||||
let want = tree.step(*id, true, &tree.world(j.a.occurrence, &work)?, &work)?;
|
||||
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
|
||||
let at = apply_point(fb, p);
|
||||
let d = apply_point(&want, at) - apply_point(&tb, at);
|
||||
out.extend([d.x, d.y, d.z]);
|
||||
}
|
||||
}
|
||||
closure_gaps(tree, closing, &work, scale, &mut out)?;
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
};
|
||||
// Already closed, with no held value moving. (A moving one is followed all the way: a
|
||||
@@ -746,8 +928,28 @@ fn largest(r: &[f64]) -> f64 {
|
||||
|
||||
/// Levenberg–Marquardt: move `x` (kept within `bounds`) until every value of `f` is within
|
||||
/// `tol` of zero. False when it can't get there.
|
||||
fn least_squares(f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>, bounds: &[(f64, f64)], tol: f64) -> bool {
|
||||
/// The Jacobian of `f` (`m` values) at `x` by central differences, row-major.
|
||||
fn jacobian(f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &[f64], m: usize) -> Option<Vec<f64>> {
|
||||
const H: f64 = 1e-6;
|
||||
let n = x.len();
|
||||
let mut jac = vec![0.0; m * n];
|
||||
for k in 0..n {
|
||||
let (mut xp, mut xm) = (x.to_vec(), x.to_vec());
|
||||
if let (Some(p), Some(q)) = (xp.get_mut(k), xm.get_mut(k)) {
|
||||
*p += H;
|
||||
*q -= H;
|
||||
}
|
||||
let (rp, rm) = (f(&xp)?, f(&xm)?);
|
||||
for (i, (a, b)) in rp.iter().zip(&rm).enumerate().take(m) {
|
||||
if let Some(slot) = jac.get_mut(i * n + k) {
|
||||
*slot = (a - b) / (2.0 * H);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(jac)
|
||||
}
|
||||
|
||||
fn least_squares(f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>, bounds: &[(f64, f64)], tol: f64) -> bool {
|
||||
let n = x.len();
|
||||
let norm = |r: &[f64]| r.iter().map(|v| v * v).sum::<f64>();
|
||||
let Some(mut r) = f(x) else { return false };
|
||||
@@ -761,20 +963,7 @@ fn least_squares(f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>
|
||||
}
|
||||
// The Jacobian by central differences, then the normal equations JᵀJ δ = −Jᵀr.
|
||||
let m = r.len();
|
||||
let mut jac = vec![0.0; m * n];
|
||||
for k in 0..n {
|
||||
let (mut xp, mut xm) = (x.clone(), x.clone());
|
||||
if let (Some(p), Some(q)) = (xp.get_mut(k), xm.get_mut(k)) {
|
||||
*p += H;
|
||||
*q -= H;
|
||||
}
|
||||
let (Some(rp), Some(rm)) = (f(&xp), f(&xm)) else { return false };
|
||||
for (i, (a, b)) in rp.iter().zip(&rm).enumerate() {
|
||||
if let Some(slot) = jac.get_mut(i * n + k) {
|
||||
*slot = (a - b) / (2.0 * H);
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(jac) = jacobian(f, x, m) else { return false };
|
||||
let mut jtj = linalg::Mat::zeros(n, n);
|
||||
let mut g = vec![0.0; n];
|
||||
for (i, ri) in r.iter().enumerate() {
|
||||
@@ -922,6 +1111,9 @@ pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
|
||||
sol.conflicts.push((j.id, j.name.clone(), "the loop can't stay closed on the way to these values".into()));
|
||||
}
|
||||
}
|
||||
if !closing.is_empty() {
|
||||
loop_dof(&tree, &closing, asm, &vals, &mut dof);
|
||||
}
|
||||
for o in &doc.occurrences {
|
||||
dof.entry(o.id).or_insert(6);
|
||||
}
|
||||
@@ -990,6 +1182,48 @@ mod tests {
|
||||
t < 1e-9 && r < 1e-9
|
||||
}
|
||||
|
||||
/// Revolute joints 1…n (joint 1 at `start`, the others at 0) linked 1 → 2 → … → n with
|
||||
/// ratio 2, the links stored from the end of the chain; joint 3 limited to ±50.
|
||||
fn chain(n: u64, start: f64) -> Assembly {
|
||||
let origin = JointOrigin { occurrence: 0, snap: Snap::Point { point: Vec3::ZERO, z: None } };
|
||||
let joints = (1..=n)
|
||||
.map(|id| Joint {
|
||||
id,
|
||||
name: format!("J{id}"),
|
||||
kind: JointKind::Revolute,
|
||||
a: origin.clone(),
|
||||
b: origin.clone(),
|
||||
values: vec![if id == 1 { start } else { 0.0 }],
|
||||
limits: vec![(id == 3).then_some((-50.0, 50.0))],
|
||||
flip: false,
|
||||
offset: 0.0,
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
})
|
||||
.collect();
|
||||
let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0 }).collect();
|
||||
Assembly { joints, links, ..Assembly::default() }
|
||||
}
|
||||
|
||||
/// Links carry a value down a chain stored from its end, each linked value within its limits
|
||||
/// as it is set, and applying them again changes nothing.
|
||||
#[test]
|
||||
fn links_settle_whatever_order_they_are_stored_in() {
|
||||
let asm = chain(8, 10.0);
|
||||
let vals = joint_values(&asm);
|
||||
let at = |id: u64| vals.get(&id).and_then(|q| q.first()).copied().unwrap_or(f64::NAN);
|
||||
// 10 → 20 → 40 (within joint 3's limit) → 80 → … → 1280.
|
||||
assert_eq!((at(1), at(2), at(3), at(4), at(8)), (10.0, 20.0, 40.0, 80.0, 1280.0));
|
||||
let asm = chain(8, 40.0);
|
||||
let vals = joint_values(&asm);
|
||||
let at = |id: u64| vals.get(&id).and_then(|q| q.first()).copied().unwrap_or(f64::NAN);
|
||||
// Joint 3 would be 160: held at 50, and what follows it follows the 50.
|
||||
assert_eq!((at(3), at(4), at(8)), (50.0, 100.0, 1600.0));
|
||||
let mut again = vals.clone();
|
||||
apply_links(&asm, &mut again);
|
||||
assert_eq!(again, vals);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motions_compose() {
|
||||
assert!(close(&motion(JointKind::Revolute, &[std::f64::consts::FRAC_PI_2]), &rot_z(std::f64::consts::FRAC_PI_2)));
|
||||
|
||||
@@ -9,12 +9,14 @@ pub mod appearance;
|
||||
mod assembly;
|
||||
mod body_transfer;
|
||||
mod body_transfer_sheet;
|
||||
pub mod pbr_appearance;
|
||||
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
|
||||
pub mod canvas;
|
||||
mod clipboard;
|
||||
pub use clipboard::{clone_expression, remap_topology};
|
||||
pub mod config;
|
||||
pub mod construct;
|
||||
pub mod display_quality;
|
||||
mod document;
|
||||
mod eval;
|
||||
pub mod expr;
|
||||
@@ -28,6 +30,8 @@ mod history_names;
|
||||
pub use format_offsets::whole_profile_selection_from_value;
|
||||
pub mod joints;
|
||||
mod lineage;
|
||||
pub mod metadata;
|
||||
pub use metadata::Metadata;
|
||||
pub mod motion;
|
||||
mod name_lookup;
|
||||
pub mod naming;
|
||||
@@ -36,10 +40,12 @@ pub mod physical_material;
|
||||
mod piece_names;
|
||||
mod profile_refs;
|
||||
pub mod progress;
|
||||
mod walk;
|
||||
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
|
||||
pub mod plastic;
|
||||
pub mod project;
|
||||
mod project3d;
|
||||
pub mod render_settings;
|
||||
pub use project3d::iso_grid;
|
||||
pub mod sheet;
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Design properties (#1015): the design's optional top-level `metadata` object.
|
||||
//!
|
||||
//! SolidCraft reads a few well-known, unprefixed text keys from it (project, part number,
|
||||
//! revision, author, organization, description, licence) and keeps every other key exactly as
|
||||
//! it was through open, edit and save. Other tools keep their own data under one reverse-DNS
|
||||
//! key each (`com.example.tool`), holding an object.
|
||||
//!
|
||||
//! Reading never validates: a well-known key holding something other than text is kept as it
|
||||
//! is and reads as absent, so a file another tool wrote always opens. Writing through
|
||||
//! [`Metadata::set`] (the `document.metadata.set` command) checks the key, the value's kind and
|
||||
//! its size, so nothing SolidCraft writes is malformed.
|
||||
//!
|
||||
//! The map is stored as is: typed fields would refuse a design another tool had written
|
||||
//! `"author": 42` into. Everything under `metadata` is informational, never geometry, so keeping
|
||||
//! unknown keys can't leave stale model data behind (unlike keeping unknown top-level fields).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::{DocError, Result};
|
||||
|
||||
pub const PROJECT: &str = "project";
|
||||
pub const PART_NUMBER: &str = "part_number";
|
||||
pub const REVISION: &str = "revision";
|
||||
pub const AUTHOR: &str = "author";
|
||||
pub const ORGANIZATION: &str = "organization";
|
||||
pub const DESCRIPTION: &str = "description";
|
||||
pub const LICENSE: &str = "license";
|
||||
|
||||
/// SolidCraft's own keys, in the order the Design Properties dialog shows them. Every other
|
||||
/// unprefixed key is reserved for SolidCraft too.
|
||||
pub const WELL_KNOWN: [&str; 7] = [PROJECT, PART_NUMBER, REVISION, AUTHOR, ORGANIZATION, DESCRIPTION, LICENSE];
|
||||
|
||||
/// Longest key, in bytes.
|
||||
pub const MAX_KEY_BYTES: usize = 128;
|
||||
/// Longest well-known text value, in bytes.
|
||||
pub const MAX_TEXT_BYTES: usize = 4096;
|
||||
/// Largest namespaced value, as compact JSON.
|
||||
pub const MAX_VALUE_BYTES: usize = 64 * 1024;
|
||||
/// Deepest nesting of a namespaced value.
|
||||
pub const MAX_VALUE_DEPTH: usize = 32;
|
||||
/// Most keys, and largest object as compact JSON, that a change may grow the metadata to.
|
||||
pub const MAX_KEYS: usize = 256;
|
||||
pub const MAX_TOTAL_BYTES: usize = 256 * 1024;
|
||||
|
||||
/// The design's `metadata` object.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Metadata(pub Map<String, Value>);
|
||||
|
||||
/// A key SolidCraft reads as a design property.
|
||||
pub fn is_well_known(key: &str) -> bool {
|
||||
WELL_KNOWN.contains(&key)
|
||||
}
|
||||
|
||||
/// A reverse-DNS key another tool keeps its data under: two or more dot-separated labels of
|
||||
/// ASCII letters, digits, `-` and `_` (`com.example.tool`, `org.printfarm`).
|
||||
pub fn is_namespaced(key: &str) -> bool {
|
||||
key.len() <= MAX_KEY_BYTES
|
||||
&& key.split('.').count() >= 2
|
||||
&& key.split('.').all(|l| !l.is_empty() && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
|
||||
}
|
||||
|
||||
fn depth(v: &Value) -> usize {
|
||||
// Iterative, so a hostile value can't overflow the stack here.
|
||||
let mut deepest = 0;
|
||||
let mut stack = vec![(v, 1usize)];
|
||||
while let Some((v, d)) = stack.pop() {
|
||||
deepest = deepest.max(d);
|
||||
if d > MAX_VALUE_DEPTH {
|
||||
break;
|
||||
}
|
||||
match v {
|
||||
Value::Array(a) => stack.extend(a.iter().map(|x| (x, d + 1))),
|
||||
Value::Object(m) => stack.extend(m.values().map(|x| (x, d + 1))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
deepest
|
||||
}
|
||||
|
||||
fn json_len(v: &impl Serialize) -> usize {
|
||||
serde_json::to_vec(v).map_or(usize::MAX, |b| b.len())
|
||||
}
|
||||
|
||||
fn invalid(msg: String) -> DocError {
|
||||
DocError::Invalid(msg)
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&Value> {
|
||||
self.0.get(key)
|
||||
}
|
||||
|
||||
/// A text value: `None` when the key is missing, empty or holds something other than text.
|
||||
pub fn text(&self, key: &str) -> Option<&str> {
|
||||
self.0.get(key)?.as_str().filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn project(&self) -> Option<&str> {
|
||||
self.text(PROJECT)
|
||||
}
|
||||
pub fn part_number(&self) -> Option<&str> {
|
||||
self.text(PART_NUMBER)
|
||||
}
|
||||
pub fn revision(&self) -> Option<&str> {
|
||||
self.text(REVISION)
|
||||
}
|
||||
pub fn author(&self) -> Option<&str> {
|
||||
self.text(AUTHOR)
|
||||
}
|
||||
pub fn organization(&self) -> Option<&str> {
|
||||
self.text(ORGANIZATION)
|
||||
}
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
self.text(DESCRIPTION)
|
||||
}
|
||||
pub fn license(&self) -> Option<&str> {
|
||||
self.text(LICENSE)
|
||||
}
|
||||
|
||||
/// The keys SolidCraft doesn't read (other tools' data, or keys a newer SolidCraft added),
|
||||
/// kept as they are.
|
||||
pub fn others(&self) -> impl Iterator<Item = (&String, &Value)> {
|
||||
self.0.iter().filter(|(k, _)| !is_well_known(k))
|
||||
}
|
||||
|
||||
/// Set `key` to `value` after checking both; `null` (or empty text for a well-known key)
|
||||
/// removes it. Returns whether the metadata changed.
|
||||
pub fn set(&mut self, key: &str, value: Value) -> Result<bool> {
|
||||
let value = check(key, value)?;
|
||||
let Some(value) = value else { return Ok(self.remove(key)) };
|
||||
if self.0.get(key) == Some(&value) {
|
||||
return Ok(false);
|
||||
}
|
||||
let (keys, bytes) = (self.0.len(), json_len(&self.0));
|
||||
let old = self.0.insert(key.to_string(), value);
|
||||
// Growing past the limits is refused; a file already past them can still shrink.
|
||||
let (new_keys, new_bytes) = (self.0.len(), json_len(&self.0));
|
||||
if (new_keys > MAX_KEYS && new_keys > keys) || (new_bytes > MAX_TOTAL_BYTES && new_bytes > bytes) {
|
||||
match old {
|
||||
Some(old) => self.0.insert(key.to_string(), old),
|
||||
None => self.0.remove(key),
|
||||
};
|
||||
return Err(invalid(format!("design properties are limited to {MAX_KEYS} keys and {} KiB", MAX_TOTAL_BYTES / 1024)));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Remove `key`, whatever it holds. Returns whether it was there.
|
||||
pub fn remove(&mut self, key: &str) -> bool {
|
||||
self.0.remove(key).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// `value` as `key` may hold it (`None`: remove the key), or why it can't.
|
||||
fn check(key: &str, value: Value) -> Result<Option<Value>> {
|
||||
if is_well_known(key) {
|
||||
return match value {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) if s.is_empty() => Ok(None),
|
||||
Value::String(s) => {
|
||||
if s.len() > MAX_TEXT_BYTES {
|
||||
return Err(invalid(format!("`{key}` is longer than {MAX_TEXT_BYTES} bytes")));
|
||||
}
|
||||
if s.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r')) {
|
||||
return Err(invalid(format!("`{key}` has a control character")));
|
||||
}
|
||||
Ok(Some(Value::String(s)))
|
||||
}
|
||||
_ => Err(invalid(format!("`{key}` takes text"))),
|
||||
};
|
||||
}
|
||||
if is_namespaced(key) {
|
||||
return match value {
|
||||
Value::Null => Ok(None),
|
||||
Value::Object(m) => {
|
||||
let v = Value::Object(m);
|
||||
if depth(&v) > MAX_VALUE_DEPTH {
|
||||
return Err(invalid(format!("`{key}` is nested more than {MAX_VALUE_DEPTH} levels deep")));
|
||||
}
|
||||
if json_len(&v) > MAX_VALUE_BYTES {
|
||||
return Err(invalid(format!("`{key}` is larger than {} KiB", MAX_VALUE_BYTES / 1024)));
|
||||
}
|
||||
Ok(Some(v))
|
||||
}
|
||||
_ => Err(invalid(format!("`{key}` must hold an object (a tool keeps its own data as an object under its reverse-DNS key)"))),
|
||||
};
|
||||
}
|
||||
let shown: String = key.chars().take(64).collect();
|
||||
Err(invalid(format!(
|
||||
"`{shown}` is not a design property: SolidCraft's are {}; another tool's data goes under a reverse-DNS key such as `com.example.tool`",
|
||||
WELL_KNOWN.join(", ")
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn well_known_keys_read_as_text_and_ignore_other_kinds() {
|
||||
let m: Metadata = serde_json::from_value(json!({"author": "Jane Doe", "revision": 3, "project": "", "license": null})).unwrap();
|
||||
assert_eq!(m.author(), Some("Jane Doe"));
|
||||
assert_eq!(m.revision(), None, "a number is kept but is not a revision");
|
||||
assert_eq!(m.project(), None, "empty text reads as absent");
|
||||
assert_eq!(m.license(), None);
|
||||
assert_eq!(m.get("revision"), Some(&json!(3)), "kept as it was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_checks_keys_kinds_and_sizes() {
|
||||
let mut m = Metadata::default();
|
||||
assert!(m.set("author", json!("Jane")).unwrap());
|
||||
assert!(!m.set("author", json!("Jane")).unwrap(), "the same value is no change");
|
||||
assert!(m.set("com.acme.tool", json!({"shop_id": 88123, "tags": ["a", null, 1.5]})).unwrap());
|
||||
assert!(m.set("author", json!(42)).is_err(), "well-known keys take text");
|
||||
assert!(m.set("author", json!("a\u{7}b")).is_err(), "no control characters");
|
||||
assert!(m.set("description", json!("two\nlines\tand a tab")).unwrap());
|
||||
assert!(m.set("com.acme.tool", json!("text")).is_err(), "a tool's key holds an object");
|
||||
assert!(m.set("colour", json!("red")).is_err(), "unprefixed keys are reserved");
|
||||
assert!(m.set("", json!({})).is_err());
|
||||
for bad in ["com.", ".com", "com..x", "com.ex ample", "com.é", "single"] {
|
||||
assert!(m.set(bad, json!({})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(m.set("author", json!("x".repeat(MAX_TEXT_BYTES + 1))).is_err());
|
||||
assert!(m.set("org.big", json!({"a": "x".repeat(MAX_VALUE_BYTES)})).is_err());
|
||||
let mut deep = json!({});
|
||||
for _ in 0..MAX_VALUE_DEPTH {
|
||||
deep = json!({ "a": deep });
|
||||
}
|
||||
assert!(m.set("org.deep", deep).is_err());
|
||||
assert_eq!(m.author(), Some("Jane"), "a refused value leaves the old one");
|
||||
// Null and empty text remove.
|
||||
assert!(m.set("author", json!("")).unwrap());
|
||||
assert!(m.set("com.acme.tool", Value::Null).unwrap());
|
||||
assert!(!m.set("com.acme.tool", Value::Null).unwrap());
|
||||
assert_eq!(m.0.keys().collect::<Vec<_>>(), ["description"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_limits_refuse_growth_but_let_an_oversized_file_shrink() {
|
||||
let mut m = Metadata::default();
|
||||
for i in 0..MAX_KEYS {
|
||||
m.set(&format!("org.k{i}"), json!({})).unwrap();
|
||||
}
|
||||
assert!(m.set("org.one_more", json!({})).is_err());
|
||||
assert_eq!(m.0.len(), MAX_KEYS);
|
||||
// A file written past the limits by another tool still opens and can be cut down.
|
||||
let mut big = Metadata::default();
|
||||
for i in 0..MAX_KEYS + 10 {
|
||||
big.0.insert(format!("org.k{i}"), json!({}));
|
||||
}
|
||||
assert!(big.set("org.k0", Value::Null).unwrap());
|
||||
assert!(big.set("author", json!("Jane")).is_err(), "still over: no new keys");
|
||||
assert!(big.set("org.k1", json!({"replaced": true})).is_ok(), "the count doesn't grow");
|
||||
}
|
||||
}
|
||||
@@ -371,3 +371,30 @@ pub fn faces_with_stem(b: &ModelBody, name: &str) -> Vec<usize> {
|
||||
pub(crate) fn locate_on(body: &ModelBody, name: &str, p: Vec3) -> Option<FaceHit> {
|
||||
locate_in(std::iter::once((0, body)), name, p)
|
||||
}
|
||||
|
||||
/// The one face a name `model.faces` lists finds, on the body called `body` or, without one, on
|
||||
/// any body. `Err` says why there is no such face, or why the name does not name just one (the
|
||||
/// pieces of a split face, or the same face on several bodies): a command that takes a face by
|
||||
/// its name has no point to tell the pieces apart by.
|
||||
pub fn locate_one(st: &ModelState, body: Option<&str>, name: &str) -> Result<FaceHit, String> {
|
||||
if let Some(n) = body
|
||||
&& !st.bodies.iter().any(|b| b.name == n)
|
||||
{
|
||||
return Err(format!("no body `{n}`"));
|
||||
}
|
||||
let bodies: Vec<(usize, &ModelBody)> =
|
||||
st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh() && body.is_none_or(|n| b.name == n)).collect();
|
||||
// As [`locate_face`]: the names of several bodies are worked out on all cores first.
|
||||
if bodies.len() > 1 && legacy_name(name).is_none() {
|
||||
solidcraft_kernel::for_each_on_threads(&bodies, 16, |(_, b)| {
|
||||
let _ = naming::names(b);
|
||||
});
|
||||
}
|
||||
let hit = locate_in(bodies.into_iter(), name, Vec3::ZERO).ok_or_else(|| format!("no face is named `{name}` (`model.faces` lists the names)"))?;
|
||||
if hit.split {
|
||||
return Err(format!(
|
||||
"`{name}` names more than one face (pieces of a split face, or faces of several bodies): give `body`, or the name of one piece"
|
||||
));
|
||||
}
|
||||
Ok(hit)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ impl NameCell {
|
||||
}
|
||||
}
|
||||
|
||||
pub use crate::name_lookup::{EdgeHit, FaceHit, LEGACY_MARK, face_has_name, faces_with_stem, legacy_name, locate_edge, locate_face};
|
||||
pub use crate::name_lookup::{EdgeHit, FaceHit, LEGACY_MARK, face_has_name, faces_with_stem, legacy_name, locate_edge, locate_face, locate_one};
|
||||
pub use crate::piece_names::parent_piece;
|
||||
|
||||
/// Deeper chains than this are named by position only (bounded work and stack).
|
||||
@@ -756,6 +756,71 @@ pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option<String> {
|
||||
face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
|
||||
}
|
||||
|
||||
/// Where a face is, for a script: a point that lies on it and picks it, and the plane, cylinder,
|
||||
/// cone, sphere or torus it is part of (when it is one).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct FaceSpot {
|
||||
pub point: Vec3,
|
||||
pub surface: Option<solidcraft_kernel::FaceSurface>,
|
||||
}
|
||||
|
||||
/// The incentre and inradius of the roomiest triangle (greatest inradius) of each face of a mesh,
|
||||
/// of face `only` alone if given.
|
||||
fn roomiest(m: &Mesh, only: Option<usize>) -> Vec<Option<(f64, Vec3)>> {
|
||||
let mut best = vec![None; m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0)];
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let f = *f as usize;
|
||||
if only.is_some_and(|i| i != f) {
|
||||
continue;
|
||||
}
|
||||
let Some([a, b, c]) = m.tri(t) else { continue };
|
||||
// The incentre weighs each corner by the side opposite it; the inradius is area / half the perimeter.
|
||||
let (la, lb, lc) = (b.dist(c), c.dist(a), a.dist(b));
|
||||
let perimeter = la + lb + lc;
|
||||
let radius = (b - a).cross(c - a).len() / perimeter;
|
||||
let centre = (a * la + b * lb + c * lc) / perimeter;
|
||||
if let Some(slot) = best.get_mut(f)
|
||||
&& radius.is_finite()
|
||||
&& centre.is_finite()
|
||||
&& slot.is_none_or(|(old, _)| radius > old)
|
||||
{
|
||||
*slot = Some((radius, centre));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The spot of face `index` of `b` from its roomiest triangle: the triangle's incentre, so a point
|
||||
/// that lies on the face however it is shaped (the centroid of a plate with a hole is in the hole,
|
||||
/// that of a half cylinder inside the cylinder) and well away from its edges, so a pick there finds
|
||||
/// this face. On a face of an analytic surface the point is moved onto the surface itself (a facet
|
||||
/// of a curved face lies inside it by its sag), as far as that stays well inside the triangle.
|
||||
fn spot(b: &ModelBody, index: usize, roomiest: Option<(f64, Vec3)>) -> Option<FaceSpot> {
|
||||
let (radius, centre) = roomiest?;
|
||||
let surface = solidcraft_kernel::face_surface(&b.body, index);
|
||||
let point = surface.and_then(|s| s.nearest(centre)).filter(|q| q.dist(centre) <= radius * 0.5).unwrap_or(centre);
|
||||
Some(FaceSpot { point, surface })
|
||||
}
|
||||
|
||||
/// [`face_spot`] of every face of a body (`None`: the face has no triangles), in one pass.
|
||||
pub fn face_spots(b: &ModelBody) -> Vec<Option<FaceSpot>> {
|
||||
roomiest(&b.mesh(), None).into_iter().enumerate().map(|(i, best)| spot(b, i, best)).collect()
|
||||
}
|
||||
|
||||
/// Where face `index` of `b` is (`None`: it has no triangles).
|
||||
pub fn face_spot(b: &ModelBody, index: usize) -> Option<FaceSpot> {
|
||||
spot(b, index, roomiest(&b.mesh(), Some(index)).get(index).copied().flatten())
|
||||
}
|
||||
|
||||
/// A point on the face with this name, the name `model.faces` lists for it, on the body called
|
||||
/// `body` or, without one, on any body (see [`locate_one`]): what a command that takes a face as
|
||||
/// a point is given instead when the face is named.
|
||||
pub fn named_face_point(st: &ModelState, body: Option<&str>, name: &str) -> Result<Vec3, String> {
|
||||
let hit = locate_one(st, body, name)?;
|
||||
let b = st.bodies.get(hit.body).ok_or_else(|| format!("no face is named `{name}`"))?;
|
||||
face_spot(b, hit.face).map(|spot| spot.point).ok_or_else(|| format!("face `{name}` has no surface to pick"))
|
||||
}
|
||||
|
||||
/// The point on the face with this name nearest `p` (a split face: its nearest piece, with
|
||||
/// `true` for "not exactly that face": split, or merged into another); `None` when no face has
|
||||
/// the name.
|
||||
|
||||
+11
-371
@@ -9,7 +9,8 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::expr::{self, Kind, ParamDef, Value};
|
||||
use crate::{DocError, Document, FeatureKind, HoleKind, MAX_PARAMS, PatternKind, PlaneRef, Result};
|
||||
use crate::walk::Walk;
|
||||
use crate::{DocError, Document, FeatureKind, MAX_PARAMS, Result};
|
||||
|
||||
/// Whether an expression of a feature is one of its inputs, which become model parameters named
|
||||
/// in walk order (`dN`), or a coordinate of a primitive's placement point, which gets no name and
|
||||
@@ -20,376 +21,11 @@ pub(crate) enum ExprRole {
|
||||
Placement,
|
||||
}
|
||||
|
||||
/// The offsets and angles of a plane, outermost first, for `&` and `&mut` (`$m` is `mut` or
|
||||
/// nothing). Nesting is capped like every walk over a plane.
|
||||
macro_rules! plane_walk {
|
||||
($name:ident $(, $m:tt)?) => {
|
||||
fn $name<'a>(mut p: &'a $($m)? PlaneRef, f: &mut impl FnMut(&'static str, &'a $($m)? String, Kind, ExprRole)) {
|
||||
for _ in 0..64 {
|
||||
match p {
|
||||
PlaneRef::Offset { base, distance } => {
|
||||
f("Offset", distance, Kind::Length, ExprRole::Input);
|
||||
p = base;
|
||||
}
|
||||
PlaneRef::AtAngle { base, angle, .. } => {
|
||||
f("Angle", angle, Kind::Angle, ExprRole::Input);
|
||||
p = base;
|
||||
}
|
||||
PlaneRef::AlongPath { t, .. } => {
|
||||
f("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input);
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
plane_walk!(plane_exprs);
|
||||
plane_walk!(plane_exprs_mut, mut);
|
||||
|
||||
/// Every expression of a feature with its label, kind and role, in a fixed order (the inputs'
|
||||
/// order names their parameters), written once for `&` and `&mut`: `$plane` is the matching plane
|
||||
/// walk, `$refs` the matching `refs`/`refs_mut`, `$m` is `mut` or nothing.
|
||||
macro_rules! feature_walk {
|
||||
($name:ident, $plane:ident, $refs:ident $(, $m:tt)?) => {
|
||||
pub(crate) fn $name<'a>(&'a $($m)? self, f: &mut impl FnMut(&'static str, &'a $($m)? String, Kind, ExprRole)) {
|
||||
use ExprRole::{Input as I, Placement as P};
|
||||
use Kind::{Angle as A, Length as L, Unitless as U};
|
||||
const XYZ: [&str; 3] = ["X", "Y", "Z"];
|
||||
match self {
|
||||
FeatureKind::Sketch { plane, .. }
|
||||
| FeatureKind::ConstructionPlane { plane }
|
||||
| FeatureKind::Split { plane, .. }
|
||||
| FeatureKind::SplitFace { plane, .. }
|
||||
| FeatureKind::Mirror { plane, .. } => $plane(plane, f),
|
||||
FeatureKind::ConstructionAxis { def } => {
|
||||
for r in def.$refs() {
|
||||
if let crate::construct::GeoRef::Plane { plane } = r {
|
||||
$plane(plane, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::ConstructionPoint { def } => match def {
|
||||
crate::construct::PointDef::AlongPath { t, .. } => f("Distance (0 to 1)", t, U, I),
|
||||
other => {
|
||||
for r in other.$refs() {
|
||||
if let crate::construct::GeoRef::Plane { plane } = r {
|
||||
$plane(plane, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
FeatureKind::Extrude { extent, thin, .. } => {
|
||||
f("Distance", & $($m)? extent.distance, L, I);
|
||||
if let Some(d) = & $($m)? extent.distance2 {
|
||||
f("Distance 2", d, L, I);
|
||||
}
|
||||
if let Some(d) = & $($m)? extent.start_offset {
|
||||
f("Start offset", d, L, I);
|
||||
}
|
||||
if let Some(d) = & $($m)? extent.taper {
|
||||
f("Taper angle", d, A, I);
|
||||
}
|
||||
if let Some(d) = & $($m)? extent.to_offset {
|
||||
f("To offset", d, L, I);
|
||||
}
|
||||
if let Some(thin) = thin {
|
||||
f("Thickness", & $($m)? thin.thickness, L, I);
|
||||
if let Some(t) = & $($m)? thin.thickness2 {
|
||||
f("Thickness 2", t, L, I);
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::SurfaceExtrude { extent, .. } => {
|
||||
f("Distance", & $($m)? extent.distance, L, I);
|
||||
if let Some(d) = & $($m)? extent.distance2 {
|
||||
f("Distance 2", d, L, I);
|
||||
}
|
||||
if let Some(d) = & $($m)? extent.start_offset {
|
||||
f("Start offset", d, L, I);
|
||||
}
|
||||
if let Some(d) = & $($m)? extent.taper {
|
||||
f("Taper angle", d, A, I);
|
||||
}
|
||||
if let Some(d) = & $($m)? extent.to_offset {
|
||||
f("To offset", d, L, I);
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { angle, angle2, .. } | FeatureKind::SurfaceRevolve { angle, angle2, .. } => {
|
||||
f("Angle", angle, A, I);
|
||||
if let Some(a) = angle2 {
|
||||
f("Angle 2", a, A, I);
|
||||
}
|
||||
}
|
||||
FeatureKind::Fillet { radius, style, .. } => match style {
|
||||
crate::FilletStyle::Constant => f("Radius", radius, L, I),
|
||||
crate::FilletStyle::Curvature => f("Equivalent radius", radius, L, I),
|
||||
crate::FilletStyle::Chord => f("Chord length", radius, L, I),
|
||||
crate::FilletStyle::Variable { radius2, mid, .. } => {
|
||||
f("Start radius", radius, L, I);
|
||||
f("End radius", radius2, L, I);
|
||||
for (_, r) in mid {
|
||||
f("Mid radius", r, L, I);
|
||||
}
|
||||
}
|
||||
crate::FilletStyle::Spline { radii, .. } => {
|
||||
f("Start radius", radius, L, I);
|
||||
let n = radii.len();
|
||||
for (i, r) in radii.into_iter().enumerate() {
|
||||
f(if i + 1 == n { "End radius" } else { "Control radius" }, r, L, I);
|
||||
}
|
||||
}
|
||||
},
|
||||
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
|
||||
f("Distance", distance, L, I);
|
||||
if let Some(d) = distance2 {
|
||||
f("Distance 2", d, L, I);
|
||||
}
|
||||
if let Some(a) = angle {
|
||||
f("Angle", a, A, I);
|
||||
}
|
||||
}
|
||||
FeatureKind::Box { length, width, height, corner, .. } => {
|
||||
f("Length", length, L, I);
|
||||
f("Width", width, L, I);
|
||||
f("Height", height, L, I);
|
||||
for (l, c) in XYZ.into_iter().zip(corner) {
|
||||
f(l, c, L, P);
|
||||
}
|
||||
}
|
||||
FeatureKind::Cylinder { radius, height, base, .. } => {
|
||||
f("Radius", radius, L, I);
|
||||
f("Height", height, L, I);
|
||||
for (l, c) in XYZ.into_iter().zip(base) {
|
||||
f(l, c, L, P);
|
||||
}
|
||||
}
|
||||
FeatureKind::Sphere { radius, center, .. } => {
|
||||
f("Radius", radius, L, I);
|
||||
for (l, c) in XYZ.into_iter().zip(center) {
|
||||
f(l, c, L, P);
|
||||
}
|
||||
}
|
||||
FeatureKind::Torus { major, minor, center, .. } => {
|
||||
f("Major radius", major, L, I);
|
||||
f("Minor radius", minor, L, I);
|
||||
for (l, c) in XYZ.into_iter().zip(center) {
|
||||
f(l, c, L, P);
|
||||
}
|
||||
}
|
||||
FeatureKind::Pattern { pattern, .. } => match pattern {
|
||||
PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => {
|
||||
f("Quantity", count1, U, I);
|
||||
f("Spacing", spacing1, L, I);
|
||||
if let Some(c) = count2 {
|
||||
f("Quantity 2", c, U, I);
|
||||
}
|
||||
if let Some(sp) = spacing2 {
|
||||
f("Spacing 2", sp, L, I);
|
||||
}
|
||||
}
|
||||
PatternKind::Path { count, spacing, .. } => {
|
||||
f("Quantity", count, U, I);
|
||||
f("Distance", spacing, L, I);
|
||||
}
|
||||
PatternKind::Circular { count, angle, .. } => {
|
||||
f("Quantity", count, U, I);
|
||||
f("Total angle", angle, A, I);
|
||||
}
|
||||
},
|
||||
FeatureKind::Shell { thickness, .. } => f("Thickness", thickness, L, I),
|
||||
FeatureKind::Draft { angle, neutral, .. } => {
|
||||
f("Angle", angle, A, I);
|
||||
$plane(neutral, f);
|
||||
}
|
||||
FeatureKind::Hole { diameter, depth, hole, .. } => {
|
||||
f("Diameter", diameter, L, I);
|
||||
if let Some(d) = depth {
|
||||
f("Depth", d, L, I);
|
||||
}
|
||||
match hole {
|
||||
HoleKind::Simple => {}
|
||||
HoleKind::Drilled { tip_angle } => f("Tip angle", tip_angle, A, I),
|
||||
HoleKind::Counterbore { cb_diameter, cb_depth } => {
|
||||
f("Counterbore diameter", cb_diameter, L, I);
|
||||
f("Counterbore depth", cb_depth, L, I);
|
||||
}
|
||||
HoleKind::Countersink { cs_diameter, cs_angle } => {
|
||||
f("Countersink diameter", cs_diameter, L, I);
|
||||
f("Countersink angle", cs_angle, A, I);
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::Thread { length, .. } => {
|
||||
if let Some(l) = length {
|
||||
f("Length", l, L, I);
|
||||
}
|
||||
}
|
||||
FeatureKind::Scale { factor, factors, .. } => {
|
||||
f("Scale factor", factor, U, I);
|
||||
if let Some([x, y, z]) = factors {
|
||||
f("X scale", x, U, I);
|
||||
f("Y scale", y, U, I);
|
||||
f("Z scale", z, U, I);
|
||||
}
|
||||
}
|
||||
FeatureKind::OffsetFace { distance, .. } => f("Distance", distance, L, I),
|
||||
FeatureKind::BoundingSolid { margin, .. } => f("Margin", margin, L, I),
|
||||
FeatureKind::Pipe { diameter, wall, .. } => {
|
||||
f("Diameter", diameter, L, I);
|
||||
if let Some(w) = wall {
|
||||
f("Wall", w, L, I);
|
||||
}
|
||||
}
|
||||
FeatureKind::Move { translate, angle, .. } => {
|
||||
let [x, y, z] = translate;
|
||||
f("X distance", x, L, I);
|
||||
f("Y distance", y, L, I);
|
||||
f("Z distance", z, L, I);
|
||||
if let Some(a) = angle {
|
||||
f("Angle", a, A, I);
|
||||
}
|
||||
}
|
||||
FeatureKind::Emboss { depth, .. } => f("Depth", depth, L, I),
|
||||
FeatureKind::Boss { diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset, .. } => {
|
||||
f("Diameter", diameter, L, I);
|
||||
f("Height", height, L, I);
|
||||
for (l, o, k) in [
|
||||
("Hole diameter", hole_diameter, L),
|
||||
("Hole depth", hole_depth, L),
|
||||
("Draft angle", draft, A),
|
||||
("Root fillet", fillet, L),
|
||||
("Ribs", ribs, U),
|
||||
("Rib thickness", rib_thickness, L),
|
||||
("Rib length", rib_length, L),
|
||||
("Rib offset", rib_offset, L),
|
||||
] {
|
||||
if let Some(e) = o {
|
||||
f(l, e, k, I);
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::Lip { width, height, gap, .. } => {
|
||||
f("Width", width, L, I);
|
||||
f("Height", height, L, I);
|
||||
if let Some(g) = gap {
|
||||
f("Gap", g, L, I);
|
||||
}
|
||||
}
|
||||
FeatureKind::Rest { width, length, height, draft, thickness, .. } => {
|
||||
f("Width", width, L, I);
|
||||
f("Height", height, L, I);
|
||||
for (l, o, k) in [("Length", length, L), ("Draft angle", draft, A), ("Wall thickness", thickness, L)] {
|
||||
if let Some(e) = o {
|
||||
f(l, e, k, I);
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::SnapFit { length, thickness, width, catch_depth, catch_length, .. } => {
|
||||
f("Length", length, L, I);
|
||||
f("Thickness", thickness, L, I);
|
||||
f("Width", width, L, I);
|
||||
f("Catch depth", catch_depth, L, I);
|
||||
f("Catch length", catch_length, L, I);
|
||||
}
|
||||
FeatureKind::SheetContour { distance, .. } => f("Distance", distance, L, I),
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
|
||||
f("Height", height, L, I);
|
||||
f("Angle", angle, A, I);
|
||||
if let Some(r) = radius {
|
||||
f("Bend radius", r, L, I);
|
||||
}
|
||||
// The edges' own values come last, so the inputs above keep their names.
|
||||
for e in edges {
|
||||
if let Some(a) = & $($m)? e.angle {
|
||||
f("Edge angle", a, A, I);
|
||||
}
|
||||
if let Some(h) = & $($m)? e.height {
|
||||
f("Edge height", h, L, I);
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetFold { angle, radius, .. } => {
|
||||
f("Angle", angle, A, I);
|
||||
if let Some(r) = radius {
|
||||
f("Bend radius", r, L, I);
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetHem { length, gap, .. } => {
|
||||
f("Length", length, L, I);
|
||||
if let Some(g) = gap {
|
||||
f("Gap", g, L, I);
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetPerforate { diameter, pitch, margin, .. } => {
|
||||
f("Diameter", diameter, L, I);
|
||||
f("Pitch", pitch, L, I);
|
||||
if let Some(m) = margin {
|
||||
f("Margin", m, L, I);
|
||||
}
|
||||
}
|
||||
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, base, .. } => {
|
||||
f("Diameter", diameter, L, I);
|
||||
f("Pitch", pitch, L, I);
|
||||
f("Revolutions", turns, U, I);
|
||||
f("Section size", section_size, L, I);
|
||||
if let Some(a) = start_angle {
|
||||
f("Start angle", a, A, I);
|
||||
}
|
||||
for (l, c) in XYZ.into_iter().zip(base) {
|
||||
f(l, c, L, P);
|
||||
}
|
||||
}
|
||||
FeatureKind::Rib { thickness, depth, .. } => {
|
||||
f("Thickness", thickness, L, I);
|
||||
if let Some(d) = depth {
|
||||
f("Depth", d, L, I);
|
||||
}
|
||||
}
|
||||
FeatureKind::ReplaceFace { target, .. } => $plane(target, f),
|
||||
FeatureKind::Stitch { tolerance, .. } => f("Tolerance", tolerance, L, I),
|
||||
FeatureKind::MeshConvert { tolerance, .. } => f("Tolerance", tolerance, L, I),
|
||||
FeatureKind::Thicken { thickness, .. } => f("Thickness", thickness, L, I),
|
||||
FeatureKind::SurfaceTrim { plane, .. } => $plane(plane, f),
|
||||
FeatureKind::SurfaceExtend { distance, .. } => f("Distance", distance, L, I),
|
||||
FeatureKind::Unstitch { .. } | FeatureKind::ReverseNormal { .. } => {}
|
||||
FeatureKind::SurfaceOffset { distance, tolerance, .. } => {
|
||||
f("Distance", distance, L, I);
|
||||
if let Some(t) = tolerance {
|
||||
f("Tolerance", t, L, I);
|
||||
}
|
||||
}
|
||||
FeatureKind::Patch { .. } => {}
|
||||
FeatureKind::Combine { .. }
|
||||
| FeatureKind::SheetBase { .. }
|
||||
| FeatureKind::SheetUnfold { .. }
|
||||
| FeatureKind::SheetConvert { .. }
|
||||
| FeatureKind::Align { .. }
|
||||
| FeatureKind::Remove { .. }
|
||||
| FeatureKind::DeleteFace { .. }
|
||||
| FeatureKind::BoundaryFill { .. }
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
| FeatureKind::SurfaceSweep { .. }
|
||||
| FeatureKind::SurfaceLoft { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. }
|
||||
| FeatureKind::BodyTransfer { .. } => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
feature_walk!(walk_exprs, plane_exprs, refs);
|
||||
feature_walk!(walk_exprs_mut, plane_exprs_mut, refs_mut, mut);
|
||||
|
||||
/// The feature's numeric inputs: (label, expression, kind), in a fixed order.
|
||||
pub fn inputs_mut(&mut self) -> Vec<(&'static str, &mut String, Kind)> {
|
||||
let mut v = Vec::new();
|
||||
self.walk_exprs_mut(&mut |label, e, kind, role| {
|
||||
self.walk_exprs_mut(&mut |(label, e, kind, role)| {
|
||||
if role == ExprRole::Input {
|
||||
v.push((label, e, kind));
|
||||
}
|
||||
@@ -400,7 +36,7 @@ impl FeatureKind {
|
||||
/// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind).
|
||||
pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> {
|
||||
let mut v = Vec::new();
|
||||
self.walk_exprs(&mut |label, e, kind, role| {
|
||||
self.walk_exprs(&mut |(label, e, kind, role)| {
|
||||
if role == ExprRole::Input {
|
||||
v.push((label, e.clone(), kind));
|
||||
}
|
||||
@@ -410,9 +46,13 @@ impl FeatureKind {
|
||||
|
||||
/// Every expression the feature uses: its inputs, then a primitive's placement coordinates.
|
||||
pub fn expressions(&self) -> Vec<&str> {
|
||||
let mut v = Vec::new();
|
||||
self.walk_exprs(&mut |_, e, _, _| v.push(e.as_str()));
|
||||
v
|
||||
let (mut inputs, mut placement) = (Vec::new(), Vec::new());
|
||||
self.walk_exprs(&mut |(_, e, _, role)| match role {
|
||||
ExprRole::Input => inputs.push(e.as_str()),
|
||||
ExprRole::Placement => placement.push(e.as_str()),
|
||||
});
|
||||
inputs.extend(placement);
|
||||
inputs
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Persistable opaque optical parameters, independent of engineering material data.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::surface::{OpaqueSurface, SurfaceError, srgb8_to_linear};
|
||||
|
||||
/// Explicitly selected opaque response. Existing scalar looks have no such record.
|
||||
/// Construction and deserialization share the renderer's finite admission bounds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "Parameters", into = "Parameters")]
|
||||
pub struct PbrParameters {
|
||||
metallic: f64,
|
||||
roughness: f64,
|
||||
dielectric_ior: f64,
|
||||
}
|
||||
|
||||
impl Default for PbrParameters {
|
||||
fn default() -> Self {
|
||||
Self { metallic: 0.0, roughness: 0.5, dielectric_ior: 1.5 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Parameters {
|
||||
metallic: f64,
|
||||
roughness: f64,
|
||||
dielectric_ior: f64,
|
||||
}
|
||||
|
||||
impl PbrParameters {
|
||||
pub fn new(metallic: f64, roughness: f64, dielectric_ior: f64) -> Result<Self, SurfaceError> {
|
||||
OpaqueSurface::new(Vec3::ZERO, metallic, roughness, dielectric_ior)?;
|
||||
Ok(Self { metallic, roughness, dielectric_ior })
|
||||
}
|
||||
|
||||
/// Decode the containing look's sRGB8 base color once, for the actual consumer.
|
||||
pub fn surface(self, color: [u8; 3]) -> Result<OpaqueSurface, SurfaceError> {
|
||||
let [r, g, b] = color.map(srgb8_to_linear);
|
||||
OpaqueSurface::new(Vec3::new(r, g, b), self.metallic, self.roughness, self.dielectric_ior)
|
||||
}
|
||||
|
||||
pub fn metallic(self) -> f64 {
|
||||
self.metallic
|
||||
}
|
||||
pub fn roughness(self) -> f64 {
|
||||
self.roughness
|
||||
}
|
||||
pub fn dielectric_ior(self) -> f64 {
|
||||
self.dielectric_ior
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Parameters> for PbrParameters {
|
||||
type Error = SurfaceError;
|
||||
fn try_from(p: Parameters) -> Result<Self, Self::Error> {
|
||||
Self::new(p.metallic, p.roughness, p.dielectric_ior)
|
||||
}
|
||||
}
|
||||
impl From<PbrParameters> for Parameters {
|
||||
fn from(p: PbrParameters) -> Self {
|
||||
Self { metallic: p.metallic, roughness: p.roughness, dielectric_ior: p.dielectric_ior }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn optical_parameters_share_strict_admission_and_round_trip() {
|
||||
let p = PbrParameters::new(1.0, 0.3, 1.5).unwrap();
|
||||
let saved = serde_json::to_value(p).unwrap();
|
||||
assert_eq!(serde_json::from_value::<PbrParameters>(saved.clone()).unwrap(), p);
|
||||
for (key, value) in [("metallic", json!(1.1)), ("roughness", json!(0)), ("dielectric_ior", json!(0.9)), ("extra", json!(true))] {
|
||||
let mut bad = saved.clone();
|
||||
bad[key] = value;
|
||||
assert!(serde_json::from_value::<PbrParameters>(bad).is_err());
|
||||
}
|
||||
assert!(PbrParameters::new(f64::NAN, 0.3, 1.5).is_err());
|
||||
assert!(PbrParameters::new(0.0, f64::INFINITY, 1.5).is_err());
|
||||
let surface = p.surface([128, 0, 255]).unwrap();
|
||||
assert!((surface.base_color().x - 0.21586050011389926).abs() < 1e-14);
|
||||
assert_eq!(surface.base_color().z, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,7 @@ mod tests {
|
||||
sketch: feature,
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
@@ -438,6 +439,7 @@ mod tests {
|
||||
sketch: feature,
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Saved render intent, independent of runtime backend availability and GPU resources.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::{BackendId, Camera, RenderFormat, RenderLimits, RenderMode, UpAxis};
|
||||
|
||||
use crate::{DocError, Result};
|
||||
|
||||
pub const MAX_RENDER_SETUPS: usize = 32;
|
||||
pub const MAX_SETUP_NAME_BYTES: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct RenderSetup {
|
||||
pub backend: BackendId,
|
||||
pub mode: RenderMode,
|
||||
#[serde(deserialize_with = "SavedCamera::deserialize")]
|
||||
pub camera: Camera,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub format: RenderFormat,
|
||||
pub exposure_ev: f64,
|
||||
/// Procedural environment orientation, in radians. No HDRI is loaded in R2.
|
||||
pub environment_rotation: f64,
|
||||
pub environment_intensity: f64,
|
||||
/// sRGB background. Alpha/output colour management belong to rendering phases.
|
||||
pub background: [u8; 3],
|
||||
}
|
||||
|
||||
// Reuse Camera's representation and defaults, but reject misspelled saved fields. A remote
|
||||
// derive constructs Camera itself, so adding a Camera field requires updating this adapter.
|
||||
// Navigation/named-view deserialization keeps its existing compatibility policy.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(remote = "Camera", deny_unknown_fields)]
|
||||
struct SavedCamera {
|
||||
target: Vec3,
|
||||
yaw: f64,
|
||||
pitch: f64,
|
||||
#[serde(default)]
|
||||
roll: f64,
|
||||
distance: f64,
|
||||
fov: f64,
|
||||
#[serde(default)]
|
||||
up: UpAxis,
|
||||
}
|
||||
|
||||
impl Default for RenderSetup {
|
||||
fn default() -> Self {
|
||||
// The built-in descriptor owns the canonical default backend identifier.
|
||||
Self {
|
||||
backend: solidcraft_render::builtin_backend().id,
|
||||
mode: RenderMode::Preview,
|
||||
camera: Camera::default(),
|
||||
width: 1024,
|
||||
height: 768,
|
||||
format: RenderFormat::Png,
|
||||
exposure_ev: 0.0,
|
||||
environment_rotation: 0.0,
|
||||
environment_intensity: 1.0,
|
||||
background: [236, 240, 245],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderSetup {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let limits = RenderLimits::default();
|
||||
if self.width == 0
|
||||
|| self.height == 0
|
||||
|| u64::from(self.width) > limits.max_dimension
|
||||
|| u64::from(self.height) > limits.max_dimension
|
||||
|| u64::from(self.width) * u64::from(self.height) > limits.max_pixels
|
||||
{
|
||||
return Err(DocError::Invalid("render output exceeds the dimension/pixel limits".into()));
|
||||
}
|
||||
let camera = &self.camera;
|
||||
if !camera.is_valid()
|
||||
|| [camera.target.x, camera.target.y, camera.target.z, camera.distance].into_iter().any(|x| x.abs() > 1e9)
|
||||
|| [camera.yaw, camera.pitch, camera.roll].into_iter().any(|x| x.abs() > 1e6)
|
||||
|| !camera.fov.is_finite()
|
||||
|| !(0.0..std::f64::consts::PI).contains(&camera.fov)
|
||||
{
|
||||
return Err(DocError::Invalid("invalid render camera (finite mm/radians, positive distance, 0 <= fov < pi required)".into()));
|
||||
}
|
||||
if !self.exposure_ev.is_finite() || !(-32.0..=32.0).contains(&self.exposure_ev) {
|
||||
return Err(DocError::Invalid("render exposure must be finite and between -32 and 32 EV".into()));
|
||||
}
|
||||
if !self.environment_rotation.is_finite() || self.environment_rotation.abs() > 1e6 {
|
||||
return Err(DocError::Invalid("render environment rotation must be finite, bounded radians".into()));
|
||||
}
|
||||
if !self.environment_intensity.is_finite() || !(0.0..=100.0).contains(&self.environment_intensity) {
|
||||
return Err(DocError::Invalid("render environment intensity must be finite and between 0 and 100".into()));
|
||||
}
|
||||
// Desired mode/format/backend may be unavailable on this host. Saving intent is not preflight.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "UncheckedSettings")]
|
||||
pub struct RenderSettings {
|
||||
pub setups: BTreeMap<String, RenderSetup>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct UncheckedSettings {
|
||||
setups: BTreeMap<String, RenderSetup>,
|
||||
}
|
||||
|
||||
impl TryFrom<UncheckedSettings> for RenderSettings {
|
||||
type Error = DocError;
|
||||
fn try_from(value: UncheckedSettings) -> Result<Self> {
|
||||
let settings = Self { setups: value.setups };
|
||||
settings.validate()?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderSettings {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.setups.is_empty()
|
||||
}
|
||||
|
||||
pub fn validate_name(name: &str) -> Result<()> {
|
||||
if name.trim().is_empty() || name != name.trim() || name.len() > MAX_SETUP_NAME_BYTES || name.chars().any(char::is_control) {
|
||||
return Err(DocError::Invalid("render setup name must be trimmed, nonempty, at most 128 bytes and contain no control characters".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.setups.len() > MAX_RENDER_SETUPS {
|
||||
return Err(DocError::Invalid("too many render setups (maximum 32)".into()));
|
||||
}
|
||||
for (name, setup) in &self.setups {
|
||||
Self::validate_name(name)?;
|
||||
setup.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Document;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn camera_fields_are_strict_without_changing_existing_camera_defaults() {
|
||||
let mut camera = serde_json::to_value(Camera::default()).unwrap();
|
||||
camera["fov_degrees"] = serde_json::json!(45);
|
||||
let saved = serde_json::json!({"setups":{"Studio":{"camera":camera}}});
|
||||
let mut doc = serde_json::to_value(Document::default()).unwrap();
|
||||
doc["render"] = saved.clone();
|
||||
assert!(Document::from_json(&doc.to_string()).is_err());
|
||||
assert!(serde_json::from_value::<RenderSettings>(saved).is_err());
|
||||
camera.as_object_mut().unwrap().remove("fov_degrees");
|
||||
camera.as_object_mut().unwrap().remove("roll");
|
||||
camera.as_object_mut().unwrap().remove("up");
|
||||
let setup: RenderSetup = serde_json::from_value(serde_json::json!({"camera":camera})).unwrap();
|
||||
assert_eq!(setup.camera, Camera::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_and_named_setups_round_trip_without_changing_other_data() {
|
||||
let old = Document::new("Legacy");
|
||||
let mut json = serde_json::to_value(&old).unwrap();
|
||||
json["format"] = serde_json::json!("solidcraft/16");
|
||||
json.as_object_mut().unwrap().remove("render");
|
||||
let mut loaded = Document::from_json(&json.to_string()).unwrap();
|
||||
assert!(loaded.render.is_empty());
|
||||
assert_eq!(loaded.format, crate::format::format_string(crate::format::FORMAT));
|
||||
// Upstream owns 19 for editable offsets; saved render intent needs 20.
|
||||
for version in [17, 18, 19] {
|
||||
json["format"] = serde_json::json!(crate::format::format_string(version));
|
||||
assert_eq!(Document::from_json(&json.to_string()).unwrap(), loaded);
|
||||
}
|
||||
let before = loaded.clone();
|
||||
let setup = RenderSetup {
|
||||
backend: serde_json::from_value(serde_json::json!("future-backend")).unwrap(),
|
||||
exposure_ev: 2.5,
|
||||
mode: RenderMode::Final,
|
||||
format: RenderFormat::Exr,
|
||||
camera: Camera { up: solidcraft_render::UpAxis::Y, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut loaded.render).setups.insert("Studio".into(), setup);
|
||||
assert!(loaded.differs_only_in_render(&before));
|
||||
assert_eq!(Document::from_json(&loaded.to_json()).unwrap(), loaded);
|
||||
let mut previous_draft = serde_json::to_value(&loaded).unwrap();
|
||||
previous_draft["format"] = serde_json::json!("solidcraft/17");
|
||||
assert_eq!(Document::from_json(&previous_draft.to_string()).unwrap(), loaded);
|
||||
loaded.units = "in".into();
|
||||
assert!(!loaded.differs_only_in_render(&before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_document_changes_never_qualify_as_render_only() {
|
||||
let before = Document::default();
|
||||
let mut render_only = before.clone();
|
||||
Arc::make_mut(&mut render_only.render).setups.insert("Studio".into(), RenderSetup::default());
|
||||
assert!(render_only.differs_only_in_render(&before));
|
||||
for (key, value) in [
|
||||
("marker", serde_json::json!(0)),
|
||||
("materials", serde_json::json!({"Body1":"Steel"})),
|
||||
("visibility", serde_json::json!({"hidden_bodies":["Body1"]})),
|
||||
("body_components", serde_json::json!({"Body1":1})),
|
||||
("next_id", serde_json::json!(99)),
|
||||
] {
|
||||
let mut json = serde_json::to_value(&render_only).unwrap();
|
||||
json[key] = value;
|
||||
let mixed: Document = serde_json::from_value(json).unwrap();
|
||||
assert!(!mixed.differs_only_in_render(&before), "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_saved_intent_is_rejected_at_deserialization() {
|
||||
for patch in [
|
||||
serde_json::json!({"width":0}),
|
||||
serde_json::json!({"width":4097}),
|
||||
serde_json::json!({"width":4096,"height":4096}),
|
||||
serde_json::json!({"exposure_ev":33}),
|
||||
serde_json::json!({"environment_rotation":1000001}),
|
||||
serde_json::json!({"environment_intensity":-1}),
|
||||
serde_json::json!({"background":[256,0,0]}),
|
||||
serde_json::json!({"unknown":1}),
|
||||
] {
|
||||
let json = serde_json::json!({"setups":{"Studio":patch}});
|
||||
assert!(serde_json::from_value::<RenderSettings>(json).is_err());
|
||||
}
|
||||
for name in ["", " padded", "line\nfeed"] {
|
||||
assert!(RenderSettings::validate_name(name).is_err());
|
||||
}
|
||||
let mut settings = RenderSettings::default();
|
||||
for i in 0..=MAX_RENDER_SETUPS {
|
||||
settings.setups.insert(i.to_string(), RenderSetup::default());
|
||||
}
|
||||
assert!(serde_json::from_value::<RenderSettings>(serde_json::to_value(settings).unwrap()).is_err());
|
||||
let mut setup = RenderSetup::default();
|
||||
setup.camera.fov = f64::NAN;
|
||||
assert!(setup.validate().is_err());
|
||||
for field in ["yaw", "pitch", "roll", "distance", "fov"] {
|
||||
let mut value = serde_json::to_value(RenderSetup::default()).unwrap();
|
||||
value["camera"][field] = serde_json::json!(1e12);
|
||||
let setup: RenderSetup = serde_json::from_value(value).unwrap();
|
||||
assert!(setup.validate().is_err(), "{field}");
|
||||
}
|
||||
setup.camera = Camera::default();
|
||||
setup.environment_intensity = f64::INFINITY;
|
||||
assert!(setup.validate().is_err());
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector
|
||||
#[path = "sheet_convert.rs"]
|
||||
mod convert;
|
||||
pub use convert::{bend_edge_names, convert_body};
|
||||
pub(crate) use convert::{outline_loop, round_wall};
|
||||
|
||||
// Bound the aggregate geometry, including sampled curves and intermediate clipping pieces.
|
||||
// These are work limits, never geometry truncation: a larger input returns a useful error.
|
||||
@@ -446,6 +447,10 @@ pub struct SheetFlange {
|
||||
/// instead of a rectangle `leg` long.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub shape: Option<Vec<Vec2>>,
|
||||
/// A converted face's panel as drawn: `shape` with arcs where the face's edges are round
|
||||
/// (flat coordinates, counter-clockwise).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exact: Option<Loop2>,
|
||||
/// Which feature made it: `F<feature>.<k>` (persistent edge names).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub tag: String,
|
||||
@@ -842,6 +847,9 @@ impl SheetBody {
|
||||
|
||||
/// Flat region of a flange's panel.
|
||||
pub fn panel(&self, f: &SheetFlange) -> Region2 {
|
||||
if let Some(lp) = &f.exact {
|
||||
return Region2 { outer: lp.ccw(), holes: Vec::new() };
|
||||
}
|
||||
if let Some(sh) = &f.shape {
|
||||
return Region2 { outer: Loop2::polygon(sh).ccw(), holes: Vec::new() };
|
||||
}
|
||||
@@ -2167,6 +2175,7 @@ impl SheetBody {
|
||||
radius,
|
||||
leg: leg.max(0.0),
|
||||
shape: None,
|
||||
exact: None,
|
||||
tag: String::new(),
|
||||
ext: (0.0, 0.0),
|
||||
slope: (0.0, 0.0),
|
||||
@@ -2899,6 +2908,7 @@ impl SheetBody {
|
||||
slope: (0.0, 0.0),
|
||||
tear: (false, false),
|
||||
shape: None,
|
||||
exact: None,
|
||||
tag: String::new(),
|
||||
});
|
||||
Ok(self.flanges.len() - 1)
|
||||
@@ -3033,6 +3043,7 @@ impl SheetBody {
|
||||
slope: (0.0, 0.0),
|
||||
tear: (false, false),
|
||||
shape: None,
|
||||
exact: None,
|
||||
tag: String::new(),
|
||||
};
|
||||
let ba = self.allowance(&probe);
|
||||
@@ -3097,6 +3108,7 @@ impl SheetBody {
|
||||
Some(j) => {
|
||||
if let Some(f) = self.flanges.get_mut(j) {
|
||||
f.shape = Some(ccw(fixed_part.clone()));
|
||||
f.exact = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3113,6 +3125,7 @@ impl SheetBody {
|
||||
slope: (0.0, 0.0),
|
||||
tear: (false, false),
|
||||
shape: Some(ccw(moving)),
|
||||
exact: None,
|
||||
tag: String::new(),
|
||||
});
|
||||
let i = self.flanges.len() - 1;
|
||||
@@ -3238,6 +3251,7 @@ impl SheetBody {
|
||||
Some(j) => {
|
||||
if let Some(f) = self.flanges.get_mut(j) {
|
||||
f.shape = Some(ccw(fixed));
|
||||
f.exact = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3256,6 +3270,7 @@ impl SheetBody {
|
||||
slope: (0.0, 0.0),
|
||||
tear: (false, false),
|
||||
shape: Some(ccw(moving)),
|
||||
exact: None,
|
||||
tag: String::new(),
|
||||
});
|
||||
}
|
||||
@@ -4055,6 +4070,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
|
||||
slope: (0.0, 0.0),
|
||||
tear: (false, false),
|
||||
shape: None,
|
||||
exact: None,
|
||||
tag: String::new(),
|
||||
};
|
||||
let ba = sheet.allowance(&f);
|
||||
|
||||
+542
-55
@@ -11,20 +11,41 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! A bend can also be a chamfer: a flat strip between two faces, as a model without bend radii
|
||||
//! draws a bend (the chord of its arc). Picked in its middle, the strip is no face of the sheet:
|
||||
//! the faces on either side are taken out to where their planes meet, and that line is the
|
||||
//! bend's virtual sharp, bent with the rule's radius at the angle between the two faces.
|
||||
//!
|
||||
//! A bend modelled with its radius, a cylinder between two planar faces tangent to it along its
|
||||
//! length, is picked anywhere on its cylinder, on either skin, and taken the same way: the two
|
||||
//! faces out to their virtual sharp, bent there with the rule's radius and K-factor. The
|
||||
//! modelled radius gives way to the rule's, as a sharp edge's does: the flat is measured from
|
||||
//! the virtual sharps, which don't depend on it.
|
||||
//!
|
||||
//! 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.
|
||||
//! A face's outline is read exactly, as a plate's is: where it runs along a round wall standing
|
||||
//! square on the face (a rounded corner, a tooth) it is an arc of the wall's circle, and its
|
||||
//! holes and slots are cut-outs of the sheet where they lie in its panel. Its pockets (an
|
||||
//! engraved label: an inner loop whose walls end on flat floors, at most half the sheet deep)
|
||||
//! are pockets of the sheet from that skin, round their islands; on a shell the faces of the
|
||||
//! other skin are read for theirs.
|
||||
//!
|
||||
//! Not supported yet: curved faces other than bends (rolled sheet), holes and pockets that reach
|
||||
//! into a bend, pockets deeper than half the sheet or with a step or a hole in their floor, round
|
||||
//! edges that end at a bend or a cut edge, loops of bends, two faces that meet along more than
|
||||
//! one edge.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Vec2, Vec3};
|
||||
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use solidcraft_kernel::{Body, FaceSurface, face_surface};
|
||||
|
||||
use super::{RuleValues, SheetBody, SheetFlange, base_flange, polys_cross, signed_area, snap};
|
||||
use super::{FlatWork, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap};
|
||||
use crate::{DocError, ModelBody, Result, apply_point, mat_inverse};
|
||||
|
||||
const MAX_BENDS: usize = 1000;
|
||||
@@ -38,11 +59,16 @@ fn at(p: Vec3) -> String {
|
||||
}
|
||||
|
||||
/// 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]`.
|
||||
/// normal. Edge `i` runs from corner `i` to the next one, along face `across[i]`: straight, or an
|
||||
/// arc where that face is a round wall (`round[i]`: the circle's centre and the arc's middle).
|
||||
/// `holes`: its inner loops, in `plane`, each with the faces of its walls.
|
||||
struct Face {
|
||||
n: Vec3,
|
||||
pts: Vec<Vec3>,
|
||||
across: Vec<Option<usize>>,
|
||||
round: Vec<Option<(Vec3, Vec3)>>,
|
||||
plane: Plane,
|
||||
holes: Vec<(Loop2, Vec<usize>)>,
|
||||
}
|
||||
|
||||
/// A face of the sheet: the panel it hangs off (none for the fixed face), and the bend between
|
||||
@@ -68,38 +94,168 @@ fn turn<T>(per_edge: &mut [T]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// The circle (centre in `plane` coordinates, radius) of face `f` when it is a round wall
|
||||
/// standing square on the plane: a cylinder about the plane's normal `n`.
|
||||
pub(crate) fn round_wall(body: &Body, plane: &Plane, n: Vec3, f: u32) -> Option<(Vec2, f64)> {
|
||||
match face_surface(body, f as usize)? {
|
||||
FaceSurface::Cylinder { origin, axis, radius } if axis.cross(n).len() < 1e-6 => Some((plane.to_local(origin), radius)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A loop of points on a face, each with the B-rep face across the loop from it to the next
|
||||
/// point (the wall it runs along there), as segments: each run along one round wall (`wall`
|
||||
/// gives a face's circle) is an arc of that circle, the rest stays straight. Coaxial cylinders of
|
||||
/// one radius are one wall, however many faces it comes in (many systems write a round hole's
|
||||
/// wall as two half-cylinders), so such a hole is one circle. `eps`: how far off its circle a
|
||||
/// point may be.
|
||||
pub(crate) fn outline_loop(pts: &[(Vec2, Option<u32>)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Loop2 {
|
||||
Loop2 { segs: outline_segs(pts, wall, eps).into_iter().map(|(seg, _)| seg).collect() }
|
||||
}
|
||||
|
||||
/// [`outline_loop`], each segment with its wall (an arc's: that of its first point).
|
||||
fn outline_segs(pts: &[(Vec2, Option<u32>)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Vec<(Seg2, Option<u32>)> {
|
||||
let m = pts.len();
|
||||
let point = |i: usize| pts.get(i % m).map(|x| x.0).unwrap_or_default();
|
||||
let face = |i: usize| pts.get(i % m).and_then(|x| x.1);
|
||||
// The circle each segment runs along: its wall's, when both its ends lie on it.
|
||||
let circles: Vec<Option<(Vec2, f64)>> = (0..m)
|
||||
.map(|i| {
|
||||
let (c, r) = wall(pts.get(i)?.1?)?;
|
||||
((point(i).dist(c) - r).abs() <= eps && (point(i + 1).dist(c) - r).abs() <= eps).then_some((c, r))
|
||||
})
|
||||
.collect();
|
||||
let circle = |i: usize| circles.get(i % m).copied().flatten();
|
||||
let same = |i: usize, (c, r): (Vec2, f64)| circle(i).is_some_and(|(d, s)| d.dist(c) <= eps && (s - r).abs() <= eps);
|
||||
// Start where no arc runs on across the start (anywhere when the whole loop is one circle).
|
||||
let start = (0..m).find(|i| circle(*i + m - 1).is_none_or(|c| !same(*i, c))).unwrap_or(0);
|
||||
let mut segs = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < m {
|
||||
let k = start + i;
|
||||
let Some((c, r)) = circle(k) else {
|
||||
segs.push((Seg2::Line { a: point(k), b: point(k + 1) }, face(k)));
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let mut j = i + 1;
|
||||
while j < m && same(start + j, (c, r)) {
|
||||
j += 1;
|
||||
}
|
||||
let sweep: f64 = (i..j)
|
||||
.map(|s| {
|
||||
let d = (point(start + s + 1) - c).angle() - (point(start + s) - c).angle();
|
||||
(d + PI).rem_euclid(TAU) - PI
|
||||
})
|
||||
.sum();
|
||||
// Arcs of up to half a turn each.
|
||||
let pieces = ((sweep.abs() / PI - 1e-9).ceil() as usize).clamp(1, 4);
|
||||
let (a0, step) = ((point(k) - c).angle(), sweep / pieces as f64);
|
||||
segs.extend((0..pieces).map(|p| (Seg2::Arc { center: c, radius: r, start: a0 + step * p as f64, sweep: step }, face(k))));
|
||||
i = j;
|
||||
}
|
||||
segs
|
||||
}
|
||||
|
||||
/// `segs` (a closed loop) with lines that run on in one line joined: a slot's side in two faces
|
||||
/// is one line.
|
||||
fn joined_lines(segs: impl IntoIterator<Item = Seg2>, tol: f64) -> Vec<Seg2> {
|
||||
let on = |p: Vec2, a: Vec2, b: Vec2| p.extend(0.0).dist_to_segment(a.extend(0.0), b.extend(0.0)) <= tol;
|
||||
let mut out: Vec<Seg2> = Vec::new();
|
||||
for seg in segs {
|
||||
if let (Some(Seg2::Line { a, b }), Seg2::Line { b: c, .. }) = (out.last_mut(), seg)
|
||||
&& on(*b, *a, c)
|
||||
{
|
||||
*b = c;
|
||||
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");
|
||||
out.push(seg);
|
||||
}
|
||||
// Across the loop's start.
|
||||
if let (true, Some(Seg2::Line { a, b }), Some(Seg2::Line { b: c, .. })) = (out.len() > 2, out.last().copied(), out.first().copied())
|
||||
&& on(b, a, c)
|
||||
{
|
||||
out.pop();
|
||||
if let Some(Seg2::Line { a: first, .. }) = out.first_mut() {
|
||||
*first = a;
|
||||
}
|
||||
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 };
|
||||
out
|
||||
}
|
||||
|
||||
/// Face `f` of the body as a panel outline. Only a planar face can be one. Its loops are read
|
||||
/// exactly, as a plate's are (an arc where one runs along a round wall standing square on the
|
||||
/// face): the outer one is the outline, the others are its holes.
|
||||
fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
|
||||
let Some(FaceSurface::Plane { normal: n, point }) = face_surface(b, f) else {
|
||||
return err("not supported yet: curved faces in a converted sheet (only planar faces convert)");
|
||||
};
|
||||
let plane = Plane::from_normal(point, n).ok_or_else(|| DocError::Invalid("a face of the body has no plane".into()))?;
|
||||
// Its edges, each with the face on its other side.
|
||||
let mut edges: Vec<(&[Vec3], Option<usize>)> = mesh
|
||||
.edges
|
||||
.iter()
|
||||
.zip(&mesh.edge_faces)
|
||||
.filter(|(line, faces)| line.len() >= 2 && faces.iter().any(|g| *g as usize == f))
|
||||
.map(|(line, faces)| (line.as_slice(), faces.iter().map(|g| *g as usize).find(|g| *g != f)))
|
||||
.collect();
|
||||
// Chained head to tail, they close the outline and its holes: each point with the face the
|
||||
// loop runs along from it to the next point.
|
||||
let mut loops: Vec<Vec<(Vec3, Option<usize>)>> = Vec::new();
|
||||
while let Some((line, across)) = edges.pop() {
|
||||
let (Some(&start), Some(&last)) = (line.first(), line.last()) else { continue };
|
||||
let mut lp: Vec<(Vec3, Option<usize>)> = line.iter().take(line.len().saturating_sub(1)).map(|p| (*p, across)).collect();
|
||||
let mut end = last;
|
||||
while end.dist(start) > tol {
|
||||
let meets = |p: Option<&Vec3>| p.is_some_and(|p| p.dist(end) <= tol);
|
||||
let Some(k) = edges.iter().position(|(l, _)| meets(l.first()) || meets(l.last())) else {
|
||||
return err("the outline of a face of the body does not close");
|
||||
};
|
||||
let (l, g) = edges.swap_remove(k);
|
||||
let run: Vec<Vec3> = if meets(l.first()) { l.to_vec() } else { l.iter().rev().copied().collect() };
|
||||
lp.extend(run.iter().take(run.len().saturating_sub(1)).map(|p| (*p, g)));
|
||||
end = run.last().copied().unwrap_or(start);
|
||||
}
|
||||
loops.push(lp);
|
||||
}
|
||||
if !edges.is_empty() {
|
||||
return err("not supported yet: faces with holes in a converted sheet (cut them after converting)");
|
||||
let mut walls = std::collections::HashMap::new();
|
||||
let mut wall = |g: u32| *walls.entry(g).or_insert_with(|| round_wall(b, &plane, n, g));
|
||||
// Each loop with the faces of its walls.
|
||||
let mut shaped: Vec<(Vec<(Seg2, Option<u32>)>, Vec<usize>)> = loops
|
||||
.iter()
|
||||
.map(|lp| {
|
||||
let pts: Vec<(Vec2, Option<u32>)> = lp.iter().map(|(p, g)| (plane.to_local(*p), g.and_then(|g| u32::try_from(g).ok()))).collect();
|
||||
let mut walls: Vec<usize> = lp.iter().filter_map(|(_, g)| *g).collect();
|
||||
walls.sort_unstable();
|
||||
walls.dedup();
|
||||
(outline_segs(&pts, &mut wall, tol * 10.0), walls)
|
||||
})
|
||||
.collect();
|
||||
let area = |segs: &[(Seg2, Option<u32>)]| segs.iter().map(|(seg, _)| seg.area_term()).sum::<f64>().abs();
|
||||
shaped.sort_by(|x, y| area(&y.0).total_cmp(&area(&x.0)));
|
||||
let mut it = shaped.into_iter();
|
||||
let Some((outer, _)) = it.next() else { return err("a face of the body has no edges") };
|
||||
let holes = it.map(|(segs, walls)| (Loop2 { segs: joined_lines(segs.into_iter().map(|(seg, _)| seg), tol) }, walls)).collect();
|
||||
// The outline's corners, where its segments start (an arc's in quarter turns at most, so that
|
||||
// the corners alone still make an outline), each with the face along it.
|
||||
let world = |v: Vec2| plane.to_world(v);
|
||||
let (mut pts, mut tags, mut round) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for (seg, g) in outer {
|
||||
let g = g.map(|g| g as usize);
|
||||
if let Seg2::Arc { center, radius, start, sweep } = seg {
|
||||
let k = ((sweep.abs() / FRAC_PI_2 - 1e-9).ceil() as usize).clamp(1, 8);
|
||||
let step = sweep / k as f64;
|
||||
for p in 0..k {
|
||||
let a = start + step * p as f64;
|
||||
pts.push(world(center + Vec2::from_angle(a) * radius));
|
||||
tags.push(g);
|
||||
round.push(Some((world(center), world(center + Vec2::from_angle(a + step / 2.0) * radius))));
|
||||
}
|
||||
} else {
|
||||
pts.push(world(seg.start()));
|
||||
tags.push(g);
|
||||
round.push(None);
|
||||
}
|
||||
}
|
||||
// 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).
|
||||
@@ -107,31 +263,187 @@ fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
|
||||
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 arc = |k: usize| round.get(k).is_some_and(Option::is_some);
|
||||
tags.get(before) != tags.get(i) || arc(before) || arc(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();
|
||||
let keep: Vec<usize> = (0..m).filter(|i| corner(*i)).collect();
|
||||
let mut pts: Vec<Vec3> = keep.iter().filter_map(|i| pts.get(*i).copied()).collect();
|
||||
let mut tags: Vec<Option<usize>> = keep.iter().filter_map(|i| tags.get(*i).copied()).collect();
|
||||
let mut round: Vec<Option<(Vec3, Vec3)>> = keep.iter().filter_map(|i| round.get(*i).copied()).collect();
|
||||
if area_about(&pts, n) < 0.0 {
|
||||
pts.reverse();
|
||||
turn(&mut tags);
|
||||
turn(&mut round);
|
||||
}
|
||||
Ok(Face { n, pts, across: tags })
|
||||
Ok(Face { n, pts, across: tags, round, plane, holes })
|
||||
}
|
||||
|
||||
/// 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.
|
||||
// The face most of it looks through to, from points in its largest triangles: a few of them
|
||||
// may look at the floor of a label engraved in the other skin.
|
||||
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 mut tris: Vec<[Vec3; 3]> =
|
||||
mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).filter_map(|(t, _)| mesh.tri(t)).collect();
|
||||
tris.sort_by(|x, y| area(y).total_cmp(&area(x)));
|
||||
let mut seen: Vec<(usize, f64)> = Vec::new();
|
||||
for tri in tris.iter().take(8) {
|
||||
let middle = (tri[0] + tri[1] + tri[2]) * (1.0 / 3.0);
|
||||
for from in std::iter::once(middle).chain(tri.iter().map(|v| (middle + *v) * 0.5)) {
|
||||
let Some(g) = mesh.raycast(from, n * -1.0).and_then(|(_, hit)| mesh.tri_face.get(hit)).map(|g| *g as usize) else { continue };
|
||||
match seen.iter_mut().find(|(h, _)| *h == g) {
|
||||
Some(entry) => entry.1 += area(tri),
|
||||
None => seen.push((g, area(tri))),
|
||||
}
|
||||
}
|
||||
}
|
||||
let g = seen.iter().max_by(|x, y| x.1.total_cmp(&y.1))?.0;
|
||||
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)))
|
||||
}
|
||||
|
||||
/// A chamfer or a bend's cylinder picked as a bend: the face `strip` between the planar faces
|
||||
/// `a` and `b`, which meet in that bend at the line where their planes cross.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Chamfer {
|
||||
strip: usize,
|
||||
a: usize,
|
||||
b: usize,
|
||||
}
|
||||
|
||||
/// The chamfer or bend cylinder a bend point picks. A bend cylinder ([`bend_cylinder`]) is
|
||||
/// picked by a point anywhere on it (within `near`, the cylinder being drawn in chords). A
|
||||
/// chamfer by a point on the middle half of a planar face that runs between two planar faces
|
||||
/// along two parallel edges, the two faces' planes crossing in a line parallel to them and the
|
||||
/// strip facing between the two (as the chord of a bend's arc does). A point on any other face,
|
||||
/// or near a chamfer's edge, picks an edge as before.
|
||||
fn chamfer_at(mb: &ModelBody, mesh: &Mesh, p: Vec3, tol: f64, near: f64) -> Option<Chamfer> {
|
||||
let b = &mb.body;
|
||||
let (d, f) = crate::appearance::nearest_face(mb, p)?;
|
||||
if d <= near
|
||||
&& let Some(c) = bend_cylinder(b, mesh, f, tol)
|
||||
{
|
||||
return Some(c);
|
||||
}
|
||||
if d > tol {
|
||||
return None;
|
||||
}
|
||||
let strip = outline(b, mesh, f, tol).ok()?;
|
||||
let plane = |g: usize| match face_surface(b, g) {
|
||||
Some(FaceSurface::Plane { normal, .. }) => Some(normal),
|
||||
_ => None,
|
||||
};
|
||||
let m = strip.pts.len();
|
||||
let edge = |i: usize| -> Option<(Vec3, Vec3, usize)> {
|
||||
let (p, q) = (*strip.pts.get(i)?, *strip.pts.get((i + 1) % m)?);
|
||||
Some((p, (q - p).normalized()?, strip.across.get(i).copied().flatten()?))
|
||||
};
|
||||
let mut sides = Vec::new();
|
||||
for i in 0..m {
|
||||
for j in i + 1..m {
|
||||
let (Some((pa, da, a)), Some((pb, db, bb))) = (edge(i), edge(j)) else { continue };
|
||||
let (Some(na), Some(nb)) = (plane(a), plane(bb)) else { continue };
|
||||
let Some(sharp) = na.cross(nb).normalized() else { continue };
|
||||
let lined_up = da.dot(db) < -1.0 + 1e-7 && sharp.dot(da).abs() > 1.0 - 1e-7;
|
||||
if a != bb && a != f && bb != f && lined_up && strip.n.dot(na) > 1e-9 && strip.n.dot(nb) > 1e-9 {
|
||||
sides.push((pa, pb, da, a, bb));
|
||||
}
|
||||
}
|
||||
}
|
||||
let &[(pa, pb, d, a, bb)] = sides.as_slice() else { return None };
|
||||
// Its middle half: a quarter of its width or more from either edge.
|
||||
let off = |o: Vec3| (p - o - d * (p - o).dot(d)).len();
|
||||
let width = off(pa) + off(pb);
|
||||
(off(pa) >= width / 4.0 && off(pb) >= width / 4.0).then_some(Chamfer { strip: f, a, b: bb })
|
||||
}
|
||||
|
||||
/// Face `f` as a bend modelled with its radius: a cylinder with a planar face tangent to it
|
||||
/// along a straight edge parallel to its axis on either side, the two planes crossing in a line
|
||||
/// (a bend between 0 and 180°). Other faces along it (the walls of a notch) don't count.
|
||||
fn bend_cylinder(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Option<Chamfer> {
|
||||
let Some(FaceSurface::Cylinder { origin, axis, .. }) = face_surface(b, f) else { return None };
|
||||
let mut sides: Vec<(usize, Vec3)> = Vec::new();
|
||||
for (line, faces) in mesh.edges.iter().zip(&mesh.edge_faces) {
|
||||
let g = match *faces.as_slice() {
|
||||
[x, y] if x as usize == f => y as usize,
|
||||
[x, y] if y as usize == f => x as usize,
|
||||
_ => continue,
|
||||
};
|
||||
let (Some(&p), Some(&q)) = (line.first(), line.last()) else { continue };
|
||||
let along = (q - p).normalized().is_some_and(|d| d.cross(axis).len() <= 1e-6);
|
||||
if g == f || !along || line.iter().any(|x| x.dist_to_segment(p, q) > tol) || sides.iter().any(|(h, _)| *h == g) {
|
||||
continue;
|
||||
}
|
||||
// Tangent: the plane's normal is the cylinder's there, square to the axis.
|
||||
let Some(FaceSurface::Plane { normal: n, .. }) = face_surface(b, g) else { continue };
|
||||
let radial = (p - origin) - axis * (p - origin).dot(axis);
|
||||
if radial.normalized().is_some_and(|out| n.dot(out).abs() >= 1.0 - 1e-6) {
|
||||
sides.push((g, n));
|
||||
}
|
||||
}
|
||||
let &[(a, na), (bb, nb)] = sides.as_slice() else { return None };
|
||||
na.cross(nb).normalized().map(|_| Chamfer { strip: f, a, b: bb })
|
||||
}
|
||||
|
||||
/// The cylinder of bend `c` on a shell's other skin: the bend cylinder between the faces
|
||||
/// straight behind `c`'s two sides, so that a bend picked on either skin is that bend on both.
|
||||
fn other_skin(b: &Body, mesh: &Mesh, c: &Chamfer, tol: f64) -> Option<Chamfer> {
|
||||
let (x, y) = (behind(b, mesh, c.a)?.0, behind(b, mesh, c.b)?.0);
|
||||
let beside_x = mesh.edge_faces.iter().filter(|faces| faces.contains(&(x as u32))).flatten().map(|g| *g as usize);
|
||||
beside_x
|
||||
.filter(|g| *g != x && *g != c.strip)
|
||||
.find_map(|g| bend_cylinder(b, mesh, g, tol).filter(|o| (o.a, o.b) == (x, y) || (o.a, o.b) == (y, x)))
|
||||
}
|
||||
|
||||
/// Face `g`'s outline with each picked chamfer along it taken out: the edge along the chamfer
|
||||
/// moves to where `g`'s plane meets the plane of the face across the chamfer (its corners
|
||||
/// sliding along the edges before and after it), and runs along that face.
|
||||
fn without_chamfers(b: &Body, mut face: Face, g: usize, chamfers: &[Chamfer], tol: f64) -> Result<Face> {
|
||||
for c in chamfers {
|
||||
let other = if c.a == g {
|
||||
c.b
|
||||
} else if c.b == g {
|
||||
c.a
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let Some(FaceSurface::Plane { normal: n, point: o }) = face_surface(b, other) else {
|
||||
return err("the face across a chamfer picked as a bend is not flat");
|
||||
};
|
||||
let m = face.pts.len();
|
||||
let along: Vec<usize> = (0..m).filter(|i| face.across.get(*i).copied().flatten() == Some(c.strip)).collect();
|
||||
let &[k] = along.as_slice() else {
|
||||
return err("not supported yet: a chamfer picked as a bend that runs along a face more than once");
|
||||
};
|
||||
// Its corners slide along the edges before and after it: those must be straight.
|
||||
if [k + m - 1, k + 1].iter().any(|i| face.round.get(i % m).is_some_and(Option::is_some)) {
|
||||
return err("not supported yet: a round edge of a face that ends at a chamfer picked as a bend");
|
||||
}
|
||||
// Each end of the edge slides along the edge on its other side, out to the other plane.
|
||||
let slide = |corner: usize, before: usize| -> Option<Vec3> {
|
||||
let (p, q) = (*face.pts.get(corner % m)?, *face.pts.get(before % m)?);
|
||||
let d = (p - q).normalized()?;
|
||||
let s = (o - p).dot(n) / d.dot(n);
|
||||
(s.is_finite() && s >= -tol).then_some(p + d * s)
|
||||
};
|
||||
let (Some(p), Some(q)) = (slide(k, k + m - 1), slide(k + 1, k + 2)) else {
|
||||
return err("a face beside a chamfer picked as a bend does not reach the face across it");
|
||||
};
|
||||
if let Some(x) = face.pts.get_mut(k) {
|
||||
*x = p;
|
||||
}
|
||||
if let Some(x) = face.pts.get_mut((k + 1) % m) {
|
||||
*x = q;
|
||||
}
|
||||
if let Some(x) = face.across.get_mut(k) {
|
||||
*x = Some(other);
|
||||
}
|
||||
}
|
||||
Ok(face)
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
@@ -210,19 +522,58 @@ fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel's trimmed outline as drawn: an edge that was a round edge of the face is an arc again
|
||||
/// (`round`, per edge of the untrimmed outline `pts`: the circle's centre and the arc's middle),
|
||||
/// pieces of one circle in a row one arc of up to half a turn. None when the face has no round
|
||||
/// edges. A round edge keeps both its ends: one that a bend's setback or a cut edge would move is
|
||||
/// not supported yet.
|
||||
fn drawn(trimmed: &[Vec2], from: &[Option<usize>], pts: &[Vec2], round: &[Option<(Vec2, Vec2)>]) -> Result<Option<Loop2>> {
|
||||
if round.iter().all(Option::is_none) {
|
||||
return Ok(None);
|
||||
}
|
||||
let (m, n) = (trimmed.len(), pts.len());
|
||||
let mut segs: Vec<Seg2> = Vec::with_capacity(m);
|
||||
for j in 0..m {
|
||||
let (Some(&a), Some(&b)) = (trimmed.get(j), trimmed.get((j + 1) % m)) else { continue };
|
||||
let arc = from.get(j).copied().flatten().and_then(|i| Some((i, round.get(i).copied().flatten()?)));
|
||||
let Some((i, (c, mid))) = arc else {
|
||||
segs.push(Seg2::Line { a: snap(a), b: snap(b) });
|
||||
continue;
|
||||
};
|
||||
let (Some(&p), Some(&q)) = (pts.get(i), pts.get((i + 1) % n)) else { continue };
|
||||
if a.dist(p) > 1e-6 || b.dist(q) > 1e-6 {
|
||||
return err("not supported yet: a round edge of a face that ends at a bend or a cut edge of the sheet");
|
||||
}
|
||||
let (start, r) = ((p - c).angle(), c.dist(p));
|
||||
let half = ((mid - c).angle() - start + PI).rem_euclid(TAU) - PI;
|
||||
match segs.last_mut() {
|
||||
Some(Seg2::Arc { center, radius, sweep, .. })
|
||||
if center.dist(c) <= 1e-9 && (*radius - r).abs() <= 1e-9 && *sweep * half > 0.0 && (*sweep + 2.0 * half).abs() <= PI + 1e-9 =>
|
||||
{
|
||||
*sweep += 2.0 * half;
|
||||
}
|
||||
_ => segs.push(Seg2::Arc { center: c, radius: r, start, sweep: 2.0 * half }),
|
||||
}
|
||||
}
|
||||
Ok(Some(Loop2 { segs }))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// happens to be nearest. A point on a chamfer or a bend cylinder gets none either: it stays that
|
||||
/// chamfer's or cylinder's point.
|
||||
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));
|
||||
let (mesh, near, tol) = (b.mesh(), (b.body.size() * 2e-3).max(1e-3), (b.body.size() * 1e-6).max(1e-6));
|
||||
let on = |p: &Vec3| {
|
||||
chamfer_at(b, &mesh, *p, tol, near).is_none() && 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.
|
||||
/// fixed face and `bends` are points on the sharp edges that become bends, in the middle of
|
||||
/// chamfers or on bend cylinders that do (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"));
|
||||
@@ -240,10 +591,20 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
|
||||
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.
|
||||
// The two faces along each bend edge, or on either side of each chamfer or bend cylinder
|
||||
// (with a bend cylinder its other skin's too).
|
||||
let mut picks = Vec::with_capacity(bends.len());
|
||||
let mut chamfers: Vec<Chamfer> = Vec::new();
|
||||
for p in bends {
|
||||
if let Some(c) = chamfer_at(mb, &mesh, *p, tol, near) {
|
||||
picks.push((*p, (c.a, c.b)));
|
||||
for c in std::iter::once(c).chain(other_skin(b, &mesh, &c, tol)) {
|
||||
if !chamfers.iter().any(|x| x.strip == c.strip) {
|
||||
chamfers.push(c);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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()) {
|
||||
@@ -252,15 +613,31 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
|
||||
None => return err(format!("no edge of the body at the bend point {}", at(*p))),
|
||||
}
|
||||
}
|
||||
if let Some((p, _)) = picks.iter().find(|(_, (x, y))| chamfers.iter().any(|c| c.strip == *x || c.strip == *y)) {
|
||||
return err(format!("the bend at {} is on a chamfer picked as a bend (pick the chamfer or its edges, not both)", at(*p)));
|
||||
}
|
||||
let face_of = |f: usize| outline(b, &mesh, f, tol).and_then(|face| without_chamfers(b, face, f, &chamfers, tol));
|
||||
let fixed = face_of(f0).map_err(|e| DocError::Invalid(format!("the fixed face can't be a face of the sheet: {e}")))?;
|
||||
// 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));
|
||||
// On a shell, the face behind each face at a bend (its other skin, read for its pockets).
|
||||
let skin_behind: BTreeMap<usize, usize> = picks
|
||||
.iter()
|
||||
.zip(&backs)
|
||||
.filter(|_| shell.is_some())
|
||||
.filter_map(|((_, (x, y)), back)| back.map(|(bx, by)| [(*x, bx), (bx, *x), (*y, by), (by, *y)]))
|
||||
.flatten()
|
||||
.collect();
|
||||
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 joined = |x: usize, y: usize| {
|
||||
mesh.edge_faces.iter().any(|faces| faces.contains(&(x as u32)) && faces.contains(&(y as u32)))
|
||||
|| chamfers.iter().any(|c| (c.a, c.b) == (x, y) || (c.a, c.b) == (y, x))
|
||||
};
|
||||
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));
|
||||
@@ -288,8 +665,8 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
|
||||
}
|
||||
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 child =
|
||||
face_of(to).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) {
|
||||
@@ -313,12 +690,32 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
|
||||
// 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 straight = corners.zip(&base.face.round).filter(|(_, r)| r.is_none()).map(|(c, _)| c);
|
||||
let longest = straight.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());
|
||||
// How far behind a face of the sheet the body's face `g` starts and ends: a hole's wall
|
||||
// reaches through the sheet, a pocket's ends on its floor, an island's starts at the face.
|
||||
let mut points: BTreeMap<usize, Vec<Vec3>> = BTreeMap::new();
|
||||
for (tri, g) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
points.entry(*g as usize).or_default().extend(tri.iter().filter_map(|k| mesh.positions.get(*k as usize)));
|
||||
}
|
||||
let span = |g: usize, face: &Face| {
|
||||
let o = face.pts.first().copied().unwrap_or_default();
|
||||
let ds = points.get(&g).into_iter().flatten().map(|p| (o - *p).dot(face.n));
|
||||
ds.fold((f64::INFINITY, 0.0), |(lo, hi): (f64, f64), d| (lo.min(d.max(0.0)), hi.max(d)))
|
||||
};
|
||||
// The span of a loop's walls: the least any starts at, and the least and most any ends at.
|
||||
let walls_span = |walls: &[usize], face: &Face| {
|
||||
walls
|
||||
.iter()
|
||||
.map(|g| span(*g, face))
|
||||
.fold((f64::INFINITY, f64::INFINITY, 0.0), |(s, lo, hi): (f64, f64, f64), (a, b)| (s.min(a), lo.min(b), hi.max(b)))
|
||||
};
|
||||
let mut work = FlatWork::new();
|
||||
// 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)];
|
||||
@@ -344,16 +741,105 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
|
||||
});
|
||||
}
|
||||
let mut across = panel.face.across.clone();
|
||||
let flat = |p: Vec3| {
|
||||
let q = apply_point(&inv, p);
|
||||
Vec2::new(q.x, q.y)
|
||||
};
|
||||
let mut round: Vec<Option<(Vec2, Vec2)>> = panel.face.round.iter().map(|r| r.map(|(c, m)| (flat(c), flat(m)))).collect();
|
||||
if signed_area(&pts) < 0.0 {
|
||||
pts.reverse();
|
||||
turn(&mut by);
|
||||
turn(&mut across);
|
||||
turn(&mut round);
|
||||
}
|
||||
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))))?;
|
||||
let face_at = |e: DocError| DocError::Invalid(format!("{e} (the face at {})", at(first)));
|
||||
let (trimmed, from) = inset(&pts, &by).map_err(face_at)?;
|
||||
let exact = drawn(&trimmed, &from, &pts, &round).map_err(face_at)?;
|
||||
match fl {
|
||||
None => sh.base.outer = Loop2::polygon(&trimmed.iter().map(|q| snap(*q)).collect::<Vec<_>>()),
|
||||
Some(i) => set_panel(&mut sh, i, &trimmed),
|
||||
None => sh.base.outer = exact.unwrap_or_else(|| Loop2::polygon(&trimmed.iter().map(|q| snap(*q)).collect::<Vec<_>>())),
|
||||
Some(i) => {
|
||||
set_panel(&mut sh, i, &trimmed);
|
||||
if let (Some(lp), Some(f)) = (exact, sh.flanges.get_mut(i)) {
|
||||
f.shape = Some(trimmed.clone());
|
||||
f.exact = Some(lp);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The face's holes: cut-outs of the sheet where they lie, inside its panel. A hole's walls
|
||||
// reach through the sheet; a pocket's (an engraved label) end inside it, on floors facing
|
||||
// out as the face does: a pocket of the sheet from that skin, round its islands. On a
|
||||
// shell the face behind, the other skin, is read for its pockets too.
|
||||
let region = match fl {
|
||||
None => sh.base.clone(),
|
||||
Some(i) => sh.flanges.get(i).map(|f| sh.panel(f)).unwrap_or_default(),
|
||||
};
|
||||
let back = skin_behind.get(&pf).and_then(|g| outline(b, &mesh, *g, tol).ok());
|
||||
let skins: Vec<(&Face, bool)> = std::iter::once((&panel.face, true)).chain(back.as_ref().map(|f| (f, false))).collect();
|
||||
let bound = if skins.iter().all(|(f, _)| f.holes.is_empty()) { Vec::new() } else { region.outer.ccw().polyline(1e-3) };
|
||||
for (face, front) in skins {
|
||||
for (h, walls) in &face.holes {
|
||||
let p = face.plane.to_world(h.centroid());
|
||||
let (_, reach, deepest) = walls_span(walls, face);
|
||||
let through = reach >= t - tol;
|
||||
if through && !front {
|
||||
continue;
|
||||
}
|
||||
let cut = sh.region_to_flat(fl, &face.plane, &Region2 { outer: h.clone(), holes: Vec::new() })?;
|
||||
let ring = cut.outer.polyline(1e-3);
|
||||
if !ring.iter().all(|q| point_in(&bound, *q)) || polys_cross(&bound, &ring) {
|
||||
let kind = if through { "hole" } else { "pocket" };
|
||||
return err(format!("not supported yet: the {kind} at {} reaches into a bend (cut it after converting)", at(p)));
|
||||
}
|
||||
if through {
|
||||
sh.holes.push(cut);
|
||||
continue;
|
||||
}
|
||||
let pocket =
|
||||
|why: &str| err(format!("not supported yet: the pocket at {} in a face of the sheet {why} (cut it after converting)", at(p)));
|
||||
let eps = t * 1e-3;
|
||||
if reach > t / 2.0 + eps {
|
||||
return pocket(&format!("ends {reach:.3} deep, more than half the sheet's {t:.3}"));
|
||||
}
|
||||
if deepest - reach > eps {
|
||||
return pocket("has walls of more than one depth");
|
||||
}
|
||||
// Its floors: the faces at its depth beside its walls, facing out as the face does.
|
||||
let mut floors: Vec<usize> = mesh
|
||||
.edge_faces
|
||||
.iter()
|
||||
.filter(|fs| fs.iter().any(|g| walls.contains(&(*g as usize))))
|
||||
.flatten()
|
||||
.map(|g| *g as usize)
|
||||
.filter(|g| {
|
||||
!walls.contains(g)
|
||||
&& matches!(face_surface(b, *g), Some(FaceSurface::Plane { normal, .. }) if normal.dot(face.n) > 1.0 - 1e-9)
|
||||
})
|
||||
.filter(|g| {
|
||||
let (lo, hi) = span(*g, face);
|
||||
(lo - reach).abs() <= eps && (hi - reach).abs() <= eps
|
||||
})
|
||||
.collect();
|
||||
floors.sort_unstable();
|
||||
floors.dedup();
|
||||
if floors.is_empty() {
|
||||
return pocket("has no flat floor");
|
||||
}
|
||||
// The islands left standing in it (a letter's counter): walls from a floor up to the face.
|
||||
let mut islands = Vec::new();
|
||||
for g in floors {
|
||||
let floor = outline(b, &mesh, g, tol)?;
|
||||
for (k, kw) in &floor.holes {
|
||||
let (start, lo, hi) = walls_span(kw, face);
|
||||
if start > eps || (lo - reach).abs() > eps || (hi - reach).abs() > eps {
|
||||
return pocket("has a hole or a step in its floor");
|
||||
}
|
||||
islands.push(sh.region_to_flat(fl, &floor.plane, &Region2 { outer: k.clone(), holes: Vec::new() })?.outer.reversed());
|
||||
}
|
||||
}
|
||||
let (z0, z1) = if front { (0.0, reach) } else { (t - reach, t) };
|
||||
sh.record_pocket(Region2 { outer: cut.outer, holes: islands }, z0, z1, &mut work)?;
|
||||
}
|
||||
}
|
||||
// Its children: flanges on the trimmed edges they bend at.
|
||||
let m = trimmed.len();
|
||||
@@ -379,6 +865,7 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
|
||||
ext: (0.0, 0.0),
|
||||
slope: (0.0, 0.0),
|
||||
shape: None,
|
||||
exact: None,
|
||||
tag: String::new(),
|
||||
tear: (false, false),
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ fn plate_doc() -> (Document, u64) {
|
||||
sketch: s,
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "20".into(),
|
||||
direction: Direction::Positive,
|
||||
distance2: None,
|
||||
@@ -125,6 +126,7 @@ fn box_fillet_cut_timeline() {
|
||||
sketch: s2,
|
||||
profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] },
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "20".into(),
|
||||
direction: Direction::Positive,
|
||||
distance2: None,
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
//! The walks over a feature: its expressions (`params`), its references to the rest of the design
|
||||
//! (`feature_refs`) and its geometry (`feature_geometry`). `#[derive(Walk)]`
|
||||
//! (`solidcraft-derive`) generates them from `#[walk(...)]` roles on the fields of `FeatureKind`
|
||||
//! and the types inside it, so what a field is, is written once, where it is declared. The
|
||||
//! traits here carry the walks through the types the fields have: options, lists, arrays, planes
|
||||
//! and construction definitions.
|
||||
|
||||
pub(crate) use solidcraft_geom::Vec3;
|
||||
|
||||
pub(crate) use crate::ProfileSel;
|
||||
use crate::construct::{AxisDef, GeoRef, PointDef};
|
||||
pub(crate) use crate::expr::Kind;
|
||||
pub(crate) use crate::feature_geometry::Geo;
|
||||
pub(crate) use crate::feature_refs::Ref;
|
||||
pub(crate) use crate::params::ExprRole;
|
||||
use crate::{PlaneRef, PointExpr};
|
||||
|
||||
/// An expression: its label, the expression, its kind and its role.
|
||||
pub(crate) type ExprItem<'a> = (&'static str, &'a String, Kind, ExprRole);
|
||||
pub(crate) type ExprItemMut<'a> = (&'static str, &'a mut String, Kind, ExprRole);
|
||||
pub(crate) type RefItem<'a> = Ref<&'a u64, &'a String>;
|
||||
pub(crate) type RefItemMut<'a> = Ref<&'a mut u64, &'a mut String>;
|
||||
pub(crate) type GeoItem<'a> = Geo<&'a Vec3, &'a PlaneRef, &'a GeoRef, &'a PointExpr, &'a ProfileSel>;
|
||||
pub(crate) type GeoItemMut<'a> = Geo<&'a mut Vec3, &'a mut PlaneRef, &'a mut GeoRef, &'a mut PointExpr, &'a mut ProfileSel>;
|
||||
|
||||
/// A value's expressions, references and geometry, each walked in order. A type with none of
|
||||
/// them implements it empty.
|
||||
pub(crate) trait Walk {
|
||||
fn walk_exprs<'a>(&'a self, _: &mut dyn FnMut(ExprItem<'a>)) {}
|
||||
fn walk_exprs_mut<'a>(&'a mut self, _: &mut dyn FnMut(ExprItemMut<'a>)) {}
|
||||
fn walk_refs<'a>(&'a self, _: &mut dyn FnMut(RefItem<'a>)) {}
|
||||
fn walk_refs_mut<'a>(&'a mut self, _: &mut dyn FnMut(RefItemMut<'a>)) {}
|
||||
fn walk_geometry<'a>(&'a self, _: &mut dyn FnMut(GeoItem<'a>)) {}
|
||||
fn walk_geometry_mut<'a>(&'a mut self, _: &mut dyn FnMut(GeoItemMut<'a>)) {}
|
||||
}
|
||||
|
||||
impl Walk for String {}
|
||||
impl Walk for std::sync::Arc<str> {}
|
||||
impl Walk for bool {}
|
||||
impl Walk for u64 {}
|
||||
impl Walk for i8 {}
|
||||
impl Walk for f64 {}
|
||||
impl Walk for ProfileSel {}
|
||||
impl Walk for solidcraft_sketch::Sketch {}
|
||||
impl Walk for solidcraft_kernel::StepNode {}
|
||||
impl Walk for crate::document::Operation {}
|
||||
impl Walk for crate::document::Direction {}
|
||||
impl Walk for crate::document::FilletFit {}
|
||||
impl Walk for crate::document::MeshData {}
|
||||
impl Walk for crate::document::RevolveConvention {}
|
||||
impl Walk for crate::document::CoilSection {}
|
||||
impl Walk for crate::document::ThinSide {}
|
||||
impl Walk for crate::document::ExtendMode {}
|
||||
impl Walk for crate::document::RuledMode {}
|
||||
impl Walk for crate::document::RuledCorners {}
|
||||
impl Walk for crate::document::ExtrudeConvention {}
|
||||
|
||||
/// Containers walk what they hold, in order.
|
||||
macro_rules! walk_each {
|
||||
($([$($g:tt)*] $t:ty),*) => {$(
|
||||
impl<$($g)*> Walk for $t {
|
||||
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
|
||||
for t in self {
|
||||
t.walk_exprs(f)
|
||||
}
|
||||
}
|
||||
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
|
||||
for t in self {
|
||||
t.walk_exprs_mut(f)
|
||||
}
|
||||
}
|
||||
fn walk_refs<'a>(&'a self, f: &mut dyn FnMut(RefItem<'a>)) {
|
||||
for t in self {
|
||||
t.walk_refs(f)
|
||||
}
|
||||
}
|
||||
fn walk_refs_mut<'a>(&'a mut self, f: &mut dyn FnMut(RefItemMut<'a>)) {
|
||||
for t in self {
|
||||
t.walk_refs_mut(f)
|
||||
}
|
||||
}
|
||||
fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) {
|
||||
for t in self {
|
||||
t.walk_geometry(f)
|
||||
}
|
||||
}
|
||||
fn walk_geometry_mut<'a>(&'a mut self, f: &mut dyn FnMut(GeoItemMut<'a>)) {
|
||||
for t in self {
|
||||
t.walk_geometry_mut(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
walk_each!([T: Walk] Option<T>, [T: Walk] Vec<T>, [T: Walk, const N: usize] [T; N]);
|
||||
|
||||
/// The values of a field with a role (`T`, an optional one or a list), in order.
|
||||
pub(crate) trait Each<T> {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T));
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T));
|
||||
}
|
||||
|
||||
impl<T> Each<T> for T {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
|
||||
f(self)
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
|
||||
f(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Each<T> for Option<T> {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
|
||||
if let Some(t) = self {
|
||||
f(t)
|
||||
}
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
|
||||
if let Some(t) = self {
|
||||
f(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Each<T> for Vec<T> {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
|
||||
for t in self {
|
||||
f(t)
|
||||
}
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
|
||||
for t in self {
|
||||
f(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Each<T> for Option<Vec<T>> {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
|
||||
for t in self.iter().flatten() {
|
||||
f(t)
|
||||
}
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
|
||||
for t in self.iter_mut().flatten() {
|
||||
f(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The expressions of an expression field, each with its index and their count (for its label).
|
||||
pub(crate) trait ExprField {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String));
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String));
|
||||
}
|
||||
|
||||
impl ExprField for String {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
|
||||
f(0, 1, self)
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
|
||||
f(0, 1, self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A position along an edge with its radius (a variable fillet's mid radii).
|
||||
impl ExprField for (f64, String) {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
|
||||
f(0, 1, &self.1)
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
|
||||
f(0, 1, &mut self.1)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ExprField> ExprField for Option<T> {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
|
||||
if let Some(t) = self {
|
||||
t.each(f)
|
||||
}
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
|
||||
if let Some(t) = self {
|
||||
t.each_mut(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists and arrays number their elements.
|
||||
macro_rules! expr_list {
|
||||
($([$($g:tt)*] $t:ty),*) => {$(
|
||||
impl<$($g)*> ExprField for $t {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
|
||||
let n = self.len();
|
||||
for (i, t) in self.iter().enumerate() {
|
||||
t.each(&mut |_, _, e| f(i, n, e));
|
||||
}
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
|
||||
let n = self.len();
|
||||
for (i, t) in self.iter_mut().enumerate() {
|
||||
t.each_mut(&mut |_, _, e| f(i, n, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
expr_list!([T: ExprField] Vec<T>, [T: ExprField, const N: usize] [T; N]);
|
||||
|
||||
/// An expression's label: one for every element, one per element, or worked out from the
|
||||
/// element's index and the count.
|
||||
pub(crate) trait Label {
|
||||
fn at(&self, i: usize, n: usize) -> &'static str;
|
||||
}
|
||||
|
||||
impl Label for &'static str {
|
||||
fn at(&self, _: usize, _: usize) -> &'static str {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> Label for [&'static str; N] {
|
||||
fn at(&self, i: usize, _: usize) -> &'static str {
|
||||
self.get(i).copied().unwrap_or("")
|
||||
}
|
||||
}
|
||||
|
||||
impl<F: Fn(usize, usize) -> &'static str> Label for F {
|
||||
fn at(&self, i: usize, n: usize) -> &'static str {
|
||||
self(i, n)
|
||||
}
|
||||
}
|
||||
|
||||
/// The offsets and angles of a plane, outermost first (nesting is capped like every walk over a
|
||||
/// plane), the construction plane it is built on, and the plane itself.
|
||||
impl Walk for PlaneRef {
|
||||
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
|
||||
let mut p = self;
|
||||
for _ in 0..64 {
|
||||
match p {
|
||||
PlaneRef::Offset { base, distance } => {
|
||||
f(("Offset", distance, Kind::Length, ExprRole::Input));
|
||||
p = base;
|
||||
}
|
||||
PlaneRef::AtAngle { base, angle, .. } => {
|
||||
f(("Angle", angle, Kind::Angle, ExprRole::Input));
|
||||
p = base;
|
||||
}
|
||||
PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
|
||||
let mut p = self;
|
||||
for _ in 0..64 {
|
||||
match p {
|
||||
PlaneRef::Offset { base, distance } => {
|
||||
f(("Offset", distance, Kind::Length, ExprRole::Input));
|
||||
p = base;
|
||||
}
|
||||
PlaneRef::AtAngle { base, angle, .. } => {
|
||||
f(("Angle", angle, Kind::Angle, ExprRole::Input));
|
||||
p = base;
|
||||
}
|
||||
PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn walk_refs<'a>(&'a self, f: &mut dyn FnMut(RefItem<'a>)) {
|
||||
let mut p = self;
|
||||
for _ in 0..64 {
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
|
||||
PlaneRef::Construction { name } => return f(Ref::Feature(name)),
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn walk_refs_mut<'a>(&'a mut self, f: &mut dyn FnMut(RefItemMut<'a>)) {
|
||||
let mut p = self;
|
||||
for _ in 0..64 {
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
|
||||
PlaneRef::Construction { name } => return f(Ref::Feature(name)),
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) {
|
||||
f(Geo::Plane(self))
|
||||
}
|
||||
fn walk_geometry_mut<'a>(&'a mut self, f: &mut dyn FnMut(GeoItemMut<'a>)) {
|
||||
f(Geo::Plane(self))
|
||||
}
|
||||
}
|
||||
|
||||
/// Construction axes and points: the expressions of the planes they are built from (and a point
|
||||
/// along a path's distance), and their references to the model. They name no sketches, features
|
||||
/// or bodies: copies remap their geometry references on their own (`Feature::remap_clone`).
|
||||
fn plane_exprs<'a>(refs: Vec<&'a GeoRef>, f: &mut dyn FnMut(ExprItem<'a>)) {
|
||||
for r in refs {
|
||||
if let GeoRef::Plane { plane } = r {
|
||||
plane.walk_exprs(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plane_exprs_mut<'a>(refs: Vec<&'a mut GeoRef>, f: &mut dyn FnMut(ExprItemMut<'a>)) {
|
||||
for r in refs {
|
||||
if let GeoRef::Plane { plane } = r {
|
||||
plane.walk_exprs_mut(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! construct_geometry {
|
||||
() => {
|
||||
fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) {
|
||||
for r in self.refs() {
|
||||
f(Geo::Construct(r));
|
||||
}
|
||||
}
|
||||
fn walk_geometry_mut<'a>(&'a mut self, f: &mut dyn FnMut(GeoItemMut<'a>)) {
|
||||
for r in self.refs_mut() {
|
||||
f(Geo::Construct(r));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl Walk for AxisDef {
|
||||
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
|
||||
plane_exprs(self.refs(), f)
|
||||
}
|
||||
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
|
||||
plane_exprs_mut(self.refs_mut(), f)
|
||||
}
|
||||
construct_geometry!();
|
||||
}
|
||||
|
||||
impl Walk for PointDef {
|
||||
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
|
||||
match self {
|
||||
PointDef::AlongPath { t, .. } => f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
|
||||
other => plane_exprs(other.refs(), f),
|
||||
}
|
||||
}
|
||||
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
|
||||
match self {
|
||||
PointDef::AlongPath { t, .. } => f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
|
||||
other => plane_exprs_mut(other.refs_mut(), f),
|
||||
}
|
||||
}
|
||||
construct_geometry!();
|
||||
}
|
||||
@@ -250,3 +250,20 @@ fn press_pull_on_the_enclosure_lid_is_prompt() {
|
||||
}
|
||||
assert!(total(&s) > before, "{before} -> {}", total(&s));
|
||||
}
|
||||
|
||||
/// A lip swept round a block's rim, flush with its sides and round its corners, joins it as one
|
||||
/// solid of both volumes: the block (50 × 40 with R4.5 corners, 10 high) and the lip (1 × 1 along
|
||||
/// 41 + 31 + 41 mm of straight rim and two quarter turns between radii 3.5 and 4.5).
|
||||
#[test]
|
||||
fn lip_swept_flush_round_a_corner_joins() {
|
||||
let s = replay(include_str!("../../../examples/regressions/lip_swept_flush_round_corner.json"));
|
||||
let bodies = &s.model.state().bodies;
|
||||
assert_eq!(bodies.len(), 1);
|
||||
let b = &bodies[0];
|
||||
assert!(b.body.validity().is_empty(), "{:?}", b.body.validity());
|
||||
assert_eq!(b.body.shell_count(), 1);
|
||||
let block = (50.0 * 40.0 - (4.0 - std::f64::consts::PI) * 4.5 * 4.5) * 10.0;
|
||||
let lip = 41.0 + 31.0 + 41.0 + 2.0 * std::f64::consts::PI * (4.5 * 4.5 - 3.5 * 3.5) / 4.0;
|
||||
let volume = b.body.tessellate(1e-5).unwrap().measure().volume;
|
||||
assert!((volume - block - lip).abs() < 0.02, "{volume} expected {}", block + lip);
|
||||
}
|
||||
@@ -520,7 +520,33 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fid = b.feature;
|
||||
let siblings: Vec<String> = st.bodies.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect();
|
||||
let k = siblings.iter().position(|n| *n == old).unwrap_or(0);
|
||||
// A body numbered by the counter (`BodyN`) gives its number back once it has a name of its
|
||||
// own, and every counted body after it would move down one (`Body3` becoming `Body1`) and
|
||||
// break what names it. Those later features keep the names they made too (#1109): each one's
|
||||
// own result holds them, also for bodies a later feature joins away.
|
||||
let counted = |f: &solidcraft_doc::Feature, i: usize| {
|
||||
let first = f.body_names.first().is_some_and(|n| !n.trim().is_empty());
|
||||
f.body_names.get(i).is_none_or(|n| n.trim().is_empty()) && (f.shell_piece_order || i < f.body_names.len() || !first)
|
||||
};
|
||||
let later: Vec<(u64, Vec<String>)> = match s.doc.features.iter().position(|f| f.id == fid) {
|
||||
Some(at) if s.doc.features.get(at).is_some_and(|f| counted(f, k)) => s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.skip(at + 1)
|
||||
.filter_map(|f| {
|
||||
let made: Vec<String> = s.model.result(f.id)?.state.bodies.iter().filter(|x| x.feature == f.id).map(|x| x.name.clone()).collect();
|
||||
(0..made.len()).any(|i| counted(f, i)).then_some((f.id, made))
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let d = s.doc_mut();
|
||||
for (id, made) in &later {
|
||||
if let Some(f) = d.feature_mut(*id) {
|
||||
f.body_names.clone_from(made);
|
||||
}
|
||||
}
|
||||
let f = d.feature_mut(fid).ok_or_else(|| bad(cmd, "the body's feature is gone"))?;
|
||||
while f.body_names.len() < siblings.len().max(k + 1) {
|
||||
let i = f.body_names.len();
|
||||
@@ -763,6 +789,36 @@ mod tests {
|
||||
assert_eq!(s.doc.find_feature("Hole").unwrap().participants, vec!["A".to_string()]);
|
||||
}
|
||||
|
||||
/// #1109: renaming counted bodies (`Body1`, `Body2` of one pattern) leaves the counted bodies
|
||||
/// of later features as they were (`Body3`, `Body4`), and what names them keeps working.
|
||||
#[test]
|
||||
fn renaming_counted_bodies_keeps_later_counted_names() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "center": [0, 0, 2.5], "name": "B1", "body_name": "First"}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"features": ["B1"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 10, "name": "P1"}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "center": [0, 20, 2.5], "name": "B2", "body_name": "Second"}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"features": ["B2"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 10, "name": "P2"}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "center": [10, 25, 2.5], "operation": "new", "name": "Bar"}));
|
||||
let before = names(&s);
|
||||
assert!(["Body1", "Body2", "Body3", "Body4", "Body5"].iter().all(|n| before.contains(&n.to_string())), "{before:?}");
|
||||
run(&mut s, "solid.combine", json!({"target": "Body3", "tools": ["Body5"], "operation": "join", "name": "Joined"}));
|
||||
// Renaming a body that already has a name of its own stores nothing elsewhere.
|
||||
run(&mut s, "body.rename", json!({"body": "Second", "name": "Row"}));
|
||||
assert!(s.doc.find_feature("P2").unwrap().body_names.is_empty());
|
||||
run(&mut s, "body.rename", json!({"body": "Body1", "name": "FirstCopyA"}));
|
||||
run(&mut s, "body.rename", json!({"body": "Body2", "name": "FirstCopyB"}));
|
||||
assert!(
|
||||
s.model.results.iter().all(|r| r.error.is_none()),
|
||||
"{:?}",
|
||||
s.model.results.iter().filter_map(|r| r.error.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
let after = names(&s);
|
||||
assert!(after.contains(&"Body3".to_string()) && after.contains(&"Body4".to_string()), "{after:?}");
|
||||
assert!(!after.contains(&"Body1".to_string()) && !after.contains(&"Body5".to_string()), "{after:?}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(names(&s).contains(&"Body2".to_string()) && names(&s).contains(&"Body4".to_string()), "{:?}", names(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_body_leaves_other_text_with_its_name_alone() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -30,7 +30,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("component")
|
||||
.params("bodies: [names] → one new component per body, named after it"),
|
||||
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
|
||||
CommandSpec::new("component.list", "List Components", list).noundo(),
|
||||
CommandSpec::new("component.list", "List Components", list).noundo().params(
|
||||
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
|
||||
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
|
||||
),
|
||||
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
|
||||
.icon("measure").noundo()
|
||||
.params("component?: id or name (default active); descendants?: bool (default true). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates"),
|
||||
@@ -156,9 +159,23 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "active": id }))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let doc = &s.doc;
|
||||
// Compact: what a large assembly needs to be found around in, without every name and matrix.
|
||||
let compact = bool_(p, "compact").unwrap_or(false);
|
||||
let names = |v: Vec<&str>| if compact { json!(v.len()) } else { json!(v) };
|
||||
let body_names = !compact || bool_(p, "bodies").unwrap_or(false);
|
||||
let placement = |o: &Occurrence| {
|
||||
if !compact {
|
||||
return json!(o);
|
||||
}
|
||||
let mut v = json!({"id": o.id, "name": o.name, "parent": o.parent, "grounded": o.grounded});
|
||||
if o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12) {
|
||||
v["transform"] = json!(o.transform);
|
||||
}
|
||||
v
|
||||
};
|
||||
let node = |id: u64, name: &str, parent: Option<u64>| {
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.model_body_component(b) == id).map(|b| b.name.as_str()).collect();
|
||||
let features: Vec<&str> = doc
|
||||
@@ -175,10 +192,11 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
.filter(|f| f.component == id && matches!(f.kind, FeatureKind::ConstructionPlane { .. }))
|
||||
.map(|f| f.name.as_str())
|
||||
.collect();
|
||||
let occurrences: Vec<&Occurrence> = doc.occurrences.iter().filter(|o| o.component == id).collect();
|
||||
let children: Vec<&Occurrence> = doc.occurrences.iter().filter(|o| o.parent == id).collect();
|
||||
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": features, "sketches": sketches,
|
||||
"construction": construction, "occurrences": occurrences, "children": children})
|
||||
let occurrences: Vec<Value> = doc.occurrences.iter().filter(|o| o.component == id).map(placement).collect();
|
||||
let children: Vec<Value> = doc.occurrences.iter().filter(|o| o.parent == id).map(|o| if compact { json!(o.id) } else { json!(o) }).collect();
|
||||
let bodies = if body_names { json!(bodies) } else { json!(bodies.len()) };
|
||||
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": names(features), "sketches": names(sketches),
|
||||
"construction": names(construction), "occurrences": occurrences, "children": children})
|
||||
};
|
||||
let mut out = vec![node(0, &doc.name, None)];
|
||||
for c in &doc.components {
|
||||
|
||||
@@ -159,7 +159,10 @@ fn inherited_component_appearance_and_legacy_assignment_survive_transfer() {
|
||||
let mut s = Session::default();
|
||||
let source = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
s.doc_mut().appearances.components.insert(source, solidcraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7 });
|
||||
s.doc_mut()
|
||||
.appearances
|
||||
.components
|
||||
.insert(source, solidcraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7, pbr: None });
|
||||
s.refresh();
|
||||
let look = s.doc.model_body_look(s.model.state().body("Block").unwrap()).unwrap();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
|
||||
@@ -14,9 +14,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
|
||||
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
|
||||
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
|
||||
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
|
||||
CommandSpec::new("timeline.delete", "Delete", delete)
|
||||
@@ -624,6 +626,13 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
merge(&mut v, set, 0);
|
||||
let mut nf: Feature = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
|
||||
nf.id = f.id;
|
||||
if let (FeatureKind::Extrude { extent: old, .. }, FeatureKind::Extrude { extent: new, .. }) = (&f.kind, &mut nf.kind)
|
||||
&& old.direction != solidcraft_doc::Direction::Symmetric
|
||||
&& new.direction == solidcraft_doc::Direction::Symmetric
|
||||
&& set.get("extent").and_then(|e| e.get("convention")).is_none()
|
||||
{
|
||||
new.convention = solidcraft_doc::ExtrudeConvention::TotalDistance;
|
||||
}
|
||||
if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) {
|
||||
return Err(bad(cmd, "the feature type cannot change"));
|
||||
}
|
||||
@@ -860,6 +869,17 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
if let FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } = &old.kind
|
||||
&& matches!(cid, "solid.extrude" | "surface.extrude")
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
{
|
||||
let convention = if cid == "surface.extrude" || extent.direction == solidcraft_doc::Direction::Symmetric {
|
||||
extent.convention
|
||||
} else {
|
||||
solidcraft_doc::ExtrudeConvention::TotalDistance
|
||||
};
|
||||
parameters.entry("convention").or_insert_with(|| json!(convention));
|
||||
}
|
||||
if let FeatureKind::Revolve { convention, .. } = &old.kind
|
||||
&& cid == "solid.revolve"
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{Document, ExtrudeConvention, FeatureKind};
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, params: Value) {
|
||||
s.execute(cmd, ¶ms).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
}
|
||||
|
||||
fn fixture(settings: Value) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Square"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mut p = json!({"sketch":"Square","distance":"10 mm","direction":"symmetric","operation":"new","name":"Depth","body_name":"Block"});
|
||||
p.as_object_mut().unwrap().extend(settings.as_object().unwrap().clone());
|
||||
run(&mut s, "solid.extrude", p);
|
||||
s
|
||||
}
|
||||
|
||||
fn check(s: &Session, lo: f64, hi: f64) {
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()));
|
||||
let st = s.model.state();
|
||||
let body = st.body("Block").unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
assert!((bounds.min.z - lo).abs() < 1e-7 && (bounds.max.z - hi).abs() < 1e-7, "{bounds:?}");
|
||||
let m = solidcraft_kernel::measure(&body.body).unwrap();
|
||||
assert!((m.volume - 100.0 * (hi - lo)).abs() < 1e-7, "{m:?}");
|
||||
assert!((m.centroid.z - (lo + hi) * 0.5).abs() < 1e-7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_extrude_total_distance_is_centered_and_follows_parameters() {
|
||||
for distance in ["10 mm", "-10 mm"] {
|
||||
let mut s = fixture(json!({"distance":distance,"from":[0,0,2],"start_offset":"3 mm"}));
|
||||
check(&s, 0.0, 10.0);
|
||||
let FeatureKind::Extrude { extent, .. } = &s.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") };
|
||||
assert_eq!(extent.convention, ExtrudeConvention::TotalDistance);
|
||||
let name = s.doc.find_feature("Depth").unwrap().param_names[0].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":name,"expression":"20 mm"}));
|
||||
check(&s, -5.0, 15.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s, 0.0, 10.0);
|
||||
check(&Session::new(Document::from_json(&s.doc.to_json()).unwrap()), 0.0, 10.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_extrude_switch_from_one_side_keeps_total_thickness() {
|
||||
let mut s = fixture(json!({"direction":"positive","convention":"legacy_per_side"}));
|
||||
check(&s, 0.0, 10.0);
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Depth","set":{"extent":{"direction":"symmetric"}}}));
|
||||
check(&s, -5.0, 5.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s, 0.0, 10.0);
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature":"Depth","command":"solid.extrude","params":{"sketch":"Square","distance":"10 mm","direction":"symmetric","body_name":"Block"}}),
|
||||
);
|
||||
check(&s, -5.0, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_extrude_legacy_missing_marker_preserves_geometry_and_edits() {
|
||||
let s = fixture(json!({"convention":"legacy_per_side"}));
|
||||
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
saved["format"] = json!("solidcraft/21");
|
||||
for f in saved["features"].as_array_mut().unwrap() {
|
||||
if f["type"] == "extrude" {
|
||||
f["extent"].as_object_mut().unwrap().remove("convention");
|
||||
}
|
||||
}
|
||||
let mut s = Session::new(Document::from_json(&saved.to_string()).unwrap());
|
||||
check(&s, -10.0, 10.0);
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Depth","set":{"extent":{"distance":"12 mm"}}}));
|
||||
check(&s, -12.0, 12.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature":"Depth","command":"solid.extrude","params":{"sketch":"Square","distance":"10 mm","direction":"symmetric","body_name":"Block"}}),
|
||||
);
|
||||
check(&s, -10.0, 10.0);
|
||||
check(&Session::new(Document::from_json(&s.doc.to_json()).unwrap()), -10.0, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_extrude_through_all_keeps_reach_and_two_sides_keep_independent_distances() {
|
||||
let mut s = fixture(json!({"distance2":"3 mm"}));
|
||||
check(&s, -3.0, 10.0);
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,100],"body_name":"FarAbove"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,-110],"body_name":"FarBelow"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch":"Square","direction":"symmetric","through_all":true,"operation":"cut","targets":["FarAbove","FarBelow"]}),
|
||||
);
|
||||
assert!(s.model.state().body("FarAbove").is_none());
|
||||
assert!(s.model.state().body("FarBelow").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_extrude_invalid_convention_is_atomic() {
|
||||
let mut s = fixture(json!({}));
|
||||
let before = s.doc.to_json();
|
||||
for convention in [json!("unknown"), json!(7), Value::Null] {
|
||||
assert!(s.execute("solid.extrude", &json!({"sketch":"Square","distance":10,"direction":"symmetric","convention":convention})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_extrude_thin_wall_uses_total_distance_and_to_object_remains_one_sided() {
|
||||
let s = fixture(json!({"thin":true,"thickness":"2 mm"}));
|
||||
let st = s.model.state();
|
||||
let body = st.body("Block").unwrap();
|
||||
let b = body.mesh().bounds();
|
||||
assert!((b.min.z + 5.0).abs() < 1e-7 && (b.max.z - 5.0).abs() < 1e-7);
|
||||
// A centred 2 mm wall around a 10 mm square: (12² - 8²) × 10.
|
||||
assert!((solidcraft_kernel::measure(&body.body).unwrap().volume - 800.0).abs() < 1e-7);
|
||||
check(&fixture(json!({"to":[0,0,10]})), 0.0, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_extrude_shared_extent_retains_its_existing_per_side_semantics() {
|
||||
let mut s = fixture(json!({}));
|
||||
run(&mut s, "surface.extrude", json!({"sketch":"Square","distance":"10 mm","direction":"symmetric","name":"Sheet","body_name":"Sheet"}));
|
||||
let check_sheet = |s: &Session| {
|
||||
let st = s.model.state();
|
||||
let bounds = st.body("Sheet").unwrap().mesh().bounds();
|
||||
assert!((bounds.min.z + 10.0).abs() < 1e-7 && (bounds.max.z - 10.0).abs() < 1e-7);
|
||||
};
|
||||
check_sheet(&s);
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature":"Sheet","command":"surface.extrude","params":{"sketch":"Square","distance":"10 mm","direction":"symmetric","body_name":"Sheet"}}),
|
||||
);
|
||||
check_sheet(&s);
|
||||
check_sheet(&Session::new(Document::from_json(&s.doc.to_json()).unwrap()));
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
|
||||
use solidcraft_doc::{EmbossFace, apply_point, mat_inverse, mat_mul};
|
||||
use solidcraft_geom::{Mesh, Vec2, Vec3};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::params::{bad, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
@@ -57,6 +57,22 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
|
||||
Some(ns.iter().all(|n| n.dot(n0) > 1.0 - 1e-6))
|
||||
}
|
||||
|
||||
/// A face given to a command: a point `[x, y, z]` on it (or `{point}`), or its name as
|
||||
/// `model.faces` lists it, looked up on `body` (any body without one). A name becomes a point on
|
||||
/// that face, which the feature keeps like a picked one, so it also keeps the face's name.
|
||||
/// The point is in the frame of the command's other picks: the component's own when
|
||||
/// they were mapped into it, else the world's.
|
||||
pub(super) fn face_ref(s: &Session, v: &Value, body: Option<&str>, cmd: &str) -> Result<Vec3> {
|
||||
if let Some(name) = v.as_str() {
|
||||
if name.is_empty() || name.len() > 512 {
|
||||
return Err(bad(cmd, "a face name is 1…512 characters"));
|
||||
}
|
||||
let st = if crate::frames::mapped() { s.model.state() } else { s.world_state() };
|
||||
return solidcraft_doc::naming::named_face_point(&st, body, name).map_err(|why| bad(cmd, why));
|
||||
}
|
||||
vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, "a face is a point [x, y, z] on it, or its name from model.faces"))
|
||||
}
|
||||
|
||||
/// 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()
|
||||
|
||||
@@ -13,7 +13,7 @@ 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); 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?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
.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 (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); 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?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
@@ -67,11 +67,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("face.delete", "Delete Face", delete_face)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("delete")
|
||||
.params("faces: [[x,y,z] points on the faces]; body?; heal?: bool (default true: the neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off; false: the gap stays open and the faces left are a surface body)"),
|
||||
.params("faces: [[x,y,z] points on the faces, or face names from model.faces]; body?; heal?: bool (default true: the neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off; false: the gap stays open and the faces left are a surface body)"),
|
||||
CommandSpec::new("solid.offset_face", "Offset Face", offset_face)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("offset_face")
|
||||
.params("faces: [[x,y,z] points on planar faces]; distance: expr (positive: outward); body?"),
|
||||
.params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; distance: expr (positive: outward); body?"),
|
||||
CommandSpec::new("solid.thread", "Thread", thread)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("thread")
|
||||
@@ -79,11 +79,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.shell", "Shell", shell)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("shell")
|
||||
.params("faces: [[x,y,z] points on the faces to remove]; thickness: expr; direction?: inside|outside|both; tangent_chain?: bool (also open faces joined smoothly to the picked ones); body?"),
|
||||
.params("faces: [[x,y,z] points on the faces to remove, or face names from model.faces]; thickness: expr; direction?: inside|outside|both; tangent_chain?: bool (also open faces joined smoothly to the picked ones); body?"),
|
||||
CommandSpec::new("solid.draft", "Draft", draft)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("draft")
|
||||
.params("faces: [[x,y,z]]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"),
|
||||
.params("faces: [[x,y,z] points, or face names from model.faces]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"),
|
||||
CommandSpec::new("construct.plane.offset", "Offset Plane", plane_offset)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane")
|
||||
@@ -96,7 +96,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("split")
|
||||
.params(
|
||||
"faces: [[x,y,z] points on the faces to split]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
|
||||
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
|
||||
),
|
||||
CommandSpec::new("solid.split_body", "Split Body", split_body)
|
||||
.at("SOLID", "MODIFY")
|
||||
@@ -137,9 +137,16 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
|
||||
Some((ss.plane.to_world(q), ss.plane.normal()))
|
||||
}
|
||||
|
||||
/// How far past the profile [`auto_operation`] looks for material it starts in.
|
||||
const START_PROBE_MM: f64 = 0.1;
|
||||
|
||||
/// What `operation: "auto"` means for an extrude with these parameters: `cut` when it goes
|
||||
/// into a body, otherwise `join` when the active component has a solid. Keeping an
|
||||
/// addition separate requires an explicit `new` operation.
|
||||
///
|
||||
/// "Goes into a body" is decided where the extrude starts: just past the profile in the
|
||||
/// direction it grows, or halfway along it. With the halfway point alone, a cut dragged far
|
||||
/// past a body's far side read as a Join (#1257).
|
||||
pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
|
||||
let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?;
|
||||
let dir = str_(p, "direction").map(str::to_ascii_lowercase);
|
||||
@@ -161,10 +168,13 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
|
||||
return Some("new");
|
||||
}
|
||||
let inside = |q: Vec3| bodies.iter().any(|b| b.mesh().contains(q));
|
||||
// A step into whatever is right in front of the profile, short of the far end.
|
||||
let near = |d: f64| d.signum() * (d.abs() * 0.5).min(START_PROBE_MM);
|
||||
let into = |d: f64| inside(base + n * near(d)) || inside(base + n * (d * 0.5));
|
||||
if symmetric {
|
||||
return Some(if inside(base + n * (d.abs() * 0.5)) || inside(base - n * (d.abs() * 0.5)) { "cut" } else { "join" });
|
||||
return Some(if into(d.abs()) || into(-d.abs()) { "cut" } else { "join" });
|
||||
}
|
||||
if inside(base + n * (d * 0.5)) {
|
||||
if into(d) {
|
||||
Some("cut")
|
||||
} else {
|
||||
// A profile can meet material along its side, or only around part of a
|
||||
@@ -622,8 +632,15 @@ pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent>
|
||||
if let Some(d) = &start_offset {
|
||||
check_expr(s, d, Kind::Length, cmd, "start_offset")?;
|
||||
}
|
||||
let convention = match p.get("convention") {
|
||||
// Surface Extrude shares Extent but retains its existing per-side distance semantics.
|
||||
None if cmd == "surface.extrude" => solidcraft_doc::ExtrudeConvention::LegacyPerSide,
|
||||
None => solidcraft_doc::ExtrudeConvention::TotalDistance,
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be total_distance or legacy_per_side"))?,
|
||||
};
|
||||
Ok(Extent {
|
||||
distance,
|
||||
convention,
|
||||
direction,
|
||||
distance2,
|
||||
start_offset,
|
||||
@@ -1323,7 +1340,7 @@ fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.split_face";
|
||||
let faces = face_points(p, cmd)?;
|
||||
let faces = face_points(s, p, cmd)?;
|
||||
let body = str_(p, "body").map(str::to_string);
|
||||
let xy = || solidcraft_doc::PlaneRef::Origin { name: "XY".into() };
|
||||
let kind = if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
|
||||
@@ -1542,17 +1559,17 @@ fn sketch_hole_points(s: &Session, sketch: u64) -> Vec<String> {
|
||||
ss.sketch.points.iter().enumerate().filter(|(i, p)| p.id != "origin" && !ends.contains(i)).map(|(_, p)| p.id.clone()).collect()
|
||||
}
|
||||
|
||||
fn face_points(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let a = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must be a list of points"))?;
|
||||
fn face_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let a = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must be a list of points or face names"))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
return Err(bad(cmd, "select 1…1000 faces"));
|
||||
}
|
||||
a.iter().map(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, "a face is a point [x, y, z] on it"))).collect()
|
||||
a.iter().map(|v| super::face::face_ref(s, v, str_(p, "body"), cmd)).collect()
|
||||
}
|
||||
|
||||
fn shell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.shell";
|
||||
let faces = face_points(p, cmd)?;
|
||||
let faces = face_points(s, p, cmd)?;
|
||||
let thickness = req_expr(cmd, p, "thickness")?;
|
||||
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
|
||||
let direction = str_(p, "direction").map(str::to_ascii_lowercase).unwrap_or_default();
|
||||
@@ -1574,7 +1591,7 @@ fn shell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.draft";
|
||||
let faces = face_points(p, cmd)?;
|
||||
let faces = face_points(s, p, cmd)?;
|
||||
let angle = req_expr(cmd, p, "angle")?;
|
||||
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
|
||||
let neutral = plane_param(s, p.get("neutral"), cmd)?;
|
||||
@@ -1715,14 +1732,14 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn delete_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let faces = face_points(p, "face.delete")?;
|
||||
let faces = face_points(s, p, "face.delete")?;
|
||||
let heal = bool_(p, "heal").unwrap_or(true);
|
||||
add_feature(s, p, FeatureKind::DeleteFace { faces, body: str_(p, "body").map(str::to_string), heal })
|
||||
}
|
||||
|
||||
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.offset_face";
|
||||
let faces = face_points(p, cmd)?;
|
||||
let faces = face_points(s, p, cmd)?;
|
||||
let distance = req_expr(cmd, p, "distance")?;
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
add_feature(s, p, FeatureKind::OffsetFace { faces, distance, body: str_(p, "body").map(str::to_string) })
|
||||
@@ -1770,3 +1787,7 @@ mod move_in_sketch_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "thin_extrude_tests.rs"]
|
||||
mod thin_extrude_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "extrude_convention_tests.rs"]
|
||||
mod extrude_convention_tests;
|
||||
@@ -35,7 +35,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("offset_face")
|
||||
.params("faces: [[x,y,z] points on planar faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
|
||||
.params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
|
||||
CommandSpec::new("solid.align", "Align", align)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("move")
|
||||
@@ -316,17 +316,17 @@ fn infer_emboss_selection(
|
||||
}
|
||||
Ok(chosen)
|
||||
}
|
||||
fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z]")))?;
|
||||
fn face_points(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z] or face names")))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
return Err(bad(cmd, format!("`{k}` must list 1…1000 points")));
|
||||
}
|
||||
a.iter().map(|v| vec3(v).ok_or_else(|| bad(cmd, format!("`{k}` must contain [x, y, z] points")))).collect()
|
||||
a.iter().map(|v| super::face::face_ref(s, v, str_(p, "body"), cmd)).collect()
|
||||
}
|
||||
|
||||
fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.replace_face";
|
||||
let faces = face_points(p, "faces", cmd)?;
|
||||
let faces = face_points(s, p, "faces", cmd)?;
|
||||
for f in &faces {
|
||||
if super::face::face_normal(s, *f).is_none() {
|
||||
return Err(bad(cmd, "not supported yet: replacing a face that is not planar"));
|
||||
|
||||
@@ -958,3 +958,22 @@ fn fillet_two_edges_meeting_at_a_corner_one_running_into_the_arc_wall() {
|
||||
let after = planned(&s);
|
||||
assert!(rel(before - after, removed) < 1e-3, "removed {} vs {removed}", before - after);
|
||||
}
|
||||
|
||||
/// A design saved before tapered sides were built through `taper_loop` (#1014), by the build
|
||||
/// before it: two tapered bosses apart from each other (now joined in one boolean), a fillet on
|
||||
/// a sloping side edge of one and on the top edge of the other, both saved by edge name. They
|
||||
/// still find their edges, and the part comes out the same.
|
||||
#[test]
|
||||
fn fillets_on_tapered_bosses_saved_before_still_find_their_edges() {
|
||||
let text = include_str!("../../../../tests/taper/before-1014/two_tapered_bosses_filleted.json");
|
||||
let mut s = Session::new(solidcraft_doc::Document::from_json(text).unwrap());
|
||||
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
for name in ["Extrude2", "Fillet1", "Fillet2"] {
|
||||
let id = s.doc.find_feature(name).unwrap().id;
|
||||
let r = s.model.result(id).unwrap();
|
||||
assert!(r.error.is_none() && r.warning.is_none(), "{name}: {:?} {:?}", r.error, r.warning);
|
||||
}
|
||||
// The build that saved it measured 69454.885209 mm³.
|
||||
assert!((volume - 69454.8852092132).abs() < 1e-3, "{volume}");
|
||||
assert_eq!(s.world_state().bodies.len(), 1);
|
||||
}
|
||||
+116
-18
@@ -17,11 +17,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("open")
|
||||
.key("Ctrl+O")
|
||||
.noundo()
|
||||
.params("path: .solidcraft design, or a .step/.stp file (opens as a new design)"),
|
||||
.params("path: .solidcraft design, or a .step/.stp file (opens as a new design); up?: z|y and names?: solid|product|instance for a STEP or IGES file (see file.insert_step)"),
|
||||
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: .step/.stp file (its bodies join the design as an Import base feature); name?"),
|
||||
.params("path: .step/.stp file (its bodies join the design as an Import base feature); name?; up?: z|y (default z: the file's axes are kept; y: the file has Y up, so its bodies are turned a quarter turn about X, Y becoming Z, by a Move feature after the import); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's)"),
|
||||
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
@@ -46,7 +46,14 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("export")
|
||||
.noundo()
|
||||
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10)"),
|
||||
CommandSpec::new("file.save_mesh", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
|
||||
CommandSpec::new("file.save_mesh", "Save As Mesh", save_mesh)
|
||||
.at("MESH", "MODIFY")
|
||||
.icon("export")
|
||||
.noundo()
|
||||
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id, name or program path from file.slicers (open the saved file there)"),
|
||||
CommandSpec::new("file.slicers", "Installed Slicers", slicers)
|
||||
.noundo()
|
||||
.params("→ the slicers found on this machine: [{id, name, icon?}]; pass an id or name as file.save_mesh's `slicer`"),
|
||||
];
|
||||
|
||||
fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -59,15 +66,60 @@ fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
|
||||
str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))
|
||||
}
|
||||
|
||||
/// How a STEP or IGES file comes in beyond its path: which way is up in the file, and what its
|
||||
/// bodies are named after.
|
||||
struct StepOptions {
|
||||
/// `up: "y"`: the file has Y up, so the bodies it brings are turned a quarter turn about X
|
||||
/// (Y becomes Z), as the app's "Stand it up" offer does. `z` (the default) keeps the file's axes.
|
||||
y_up: bool,
|
||||
names: solidcraft_io::StepNames,
|
||||
}
|
||||
|
||||
fn step_options(p: &Value, cmd: &str) -> Result<StepOptions> {
|
||||
let y_up = match p.get("up") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => match v.as_str().map(|u| u.trim().to_ascii_lowercase()).as_deref() {
|
||||
Some("z") => false,
|
||||
Some("y") => true,
|
||||
_ => return Err(bad(cmd, "`up` must be \"z\" (the file's axes are kept) or \"y\" (the file has Y up: its Y becomes Z)")),
|
||||
},
|
||||
};
|
||||
let names = match p.get("names") {
|
||||
None | Some(Value::Null) => solidcraft_io::StepNames::default(),
|
||||
Some(v) => {
|
||||
v.as_str().and_then(solidcraft_io::StepNames::parse).ok_or_else(|| bad(cmd, "`names` must be \"solid\", \"product\" or \"instance\""))?
|
||||
}
|
||||
};
|
||||
Ok(StepOptions { y_up, names })
|
||||
}
|
||||
|
||||
/// Read a STEP file as an Import feature.
|
||||
fn read_step(path: &str) -> Result<solidcraft_io::StepFeature> {
|
||||
fn read_step(path: &str, names: solidcraft_io::StepNames) -> Result<solidcraft_io::StepFeature> {
|
||||
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_STEP_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
solidcraft_io::step_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
solidcraft_io::step_import_feature_named(&bytes, path, names).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// Turn bodies a quarter turn about X (a file's Y up becomes Z up) with one Move feature, the
|
||||
/// same one the app's "Stand it up" offer adds, and report its id.
|
||||
fn stand_up(s: &mut Session, bodies: &[String]) -> Result<u64> {
|
||||
let kind = solidcraft_doc::FeatureKind::Move {
|
||||
bodies: bodies.to_vec(),
|
||||
translate: ["0".into(), "0".into(), "0".into()],
|
||||
rotate_axis: Some(solidcraft_geom::Vec3::X),
|
||||
angle: Some("90 deg".into()),
|
||||
copy: false,
|
||||
};
|
||||
let id = s.doc_mut().add_feature(kind, None)?;
|
||||
s.refresh();
|
||||
match s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
Some(e) => Err(EngineError::Other(e)),
|
||||
None => Ok(id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an Import feature for a STEP file to the session's design.
|
||||
fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
|
||||
let sf = read_step(path)?;
|
||||
fn add_step(s: &mut Session, path: &str, name: Option<&str>, options: &StepOptions) -> Result<Value> {
|
||||
let sf = read_step(path, options.names)?;
|
||||
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&sf.name).to_string();
|
||||
let id = s.doc_mut().add_feature(sf.kind, Some(&fname))?;
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
@@ -78,10 +130,14 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
let created: Vec<String> = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect();
|
||||
let turned = if options.y_up && !created.is_empty() { Some(stand_up(s, &created)?) } else { None };
|
||||
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": sf.warnings}))
|
||||
let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": created, "created_bodies": created, "warnings": sf.warnings});
|
||||
if let Some(turned) = turned {
|
||||
out["stand_up"] = json!(turned);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Add a MeshImport feature for a 3MF or STL file.
|
||||
@@ -119,16 +175,24 @@ fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !solidcraft_io::is_step_path(path) {
|
||||
return Err(bad(cmd, "only STEP files (.step, .stp) can be inserted"));
|
||||
}
|
||||
add_step(s, path, str_(p, "name"))
|
||||
let options = step_options(p, cmd)?;
|
||||
add_step(s, path, str_(p, "name"), &options)
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = path_arg(p, "doc.open")?;
|
||||
if !solidcraft_io::is_step_path(path) && ["up", "names"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad("doc.open", "`up` and `names` apply to STEP and IGES files only"));
|
||||
}
|
||||
if solidcraft_io::is_step_path(path) || solidcraft_io::is_mesh_path(path) {
|
||||
// A new, unsaved design holding the file's bodies.
|
||||
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
|
||||
let mut fresh = Session::new(Document::new(&stem));
|
||||
let r = if solidcraft_io::is_step_path(path) { add_step(&mut fresh, path, None)? } else { add_mesh(&mut fresh, path, None)? };
|
||||
let r = if solidcraft_io::is_step_path(path) {
|
||||
add_step(&mut fresh, path, None, &step_options(p, "doc.open")?)?
|
||||
} else {
|
||||
add_mesh(&mut fresh, path, None)?
|
||||
};
|
||||
fresh.undo.clear();
|
||||
s.replace_design(fresh);
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
@@ -318,8 +382,9 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu
|
||||
} 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 {
|
||||
// Bodies carry the colour they are shown in (appearance, material or imported).
|
||||
solidcraft_io::export(&s.doc.painted(&s.world_state()), bodies, format, &name)?
|
||||
// Bodies carry the colour they are shown in (appearance, material or imported), and the
|
||||
// file the design's properties where the format has a place for them.
|
||||
solidcraft_io::export_with_metadata(&s.doc.painted(&s.world_state()), bodies, format, &name, &s.doc.metadata)?
|
||||
};
|
||||
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
|
||||
@@ -392,10 +457,43 @@ fn export_urdf(s: &Session, path: &str, p: &Value) -> Result<Value> {
|
||||
)
|
||||
}
|
||||
|
||||
fn save_stl(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = path_arg(p, "file.save_mesh")?;
|
||||
let f = if bool_(p, "ascii").unwrap_or(false) { Format::StlAscii } else { Format::StlBinary };
|
||||
export_to(s, path, f, &string_list(p, "bodies"))
|
||||
fn save_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.save_mesh";
|
||||
let path = path_arg(p, cmd)?;
|
||||
let format = match str_(p, "format").map(str::to_ascii_lowercase).as_deref() {
|
||||
None => match std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).as_deref() {
|
||||
Some("3mf") => Format::ThreeMf,
|
||||
_ => Format::StlBinary,
|
||||
},
|
||||
Some("stl") if bool_(p, "ascii").unwrap_or(false) => Format::StlAscii,
|
||||
Some("stl") => Format::StlBinary,
|
||||
Some("3mf") => Format::ThreeMf,
|
||||
Some("3mf-tagged") => Format::ThreeMfTagged,
|
||||
Some(other) => return Err(bad(cmd, format!("unknown mesh format `{other}` (stl, 3mf, 3mf-tagged)"))),
|
||||
};
|
||||
let slicer = match str_(p, "slicer") {
|
||||
None => None,
|
||||
Some(spec) => Some(
|
||||
crate::slicers::resolve(spec).ok_or_else(|| bad(cmd, format!("no slicer `{spec}` (see file.slicers, or give the path of a program)")))?,
|
||||
),
|
||||
};
|
||||
let mut out = export_to(s, path, format, &string_list(p, "bodies"))?;
|
||||
if let (Some(slicer), Some(obj)) = (slicer, out.as_object_mut()) {
|
||||
// The file is saved either way; a slicer that won't start is reported, not fatal.
|
||||
match slicer.open(path) {
|
||||
Ok(()) => obj.insert("slicer".into(), json!(slicer.name)),
|
||||
Err(e) => obj.insert("slicer_error".into(), json!(e)),
|
||||
};
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn slicers(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let list: Vec<Value> = crate::slicers::detect()
|
||||
.iter()
|
||||
.map(|d| json!({"id": d.id, "name": d.name, "icon": d.icon.as_ref().map(|i| i.path().to_string_lossy().into_owned())}))
|
||||
.collect();
|
||||
Ok(json!({ "slicers": list }))
|
||||
}
|
||||
|
||||
fn recovery_dir(p: &Value, cmd: &str) -> Result<std::path::PathBuf> {
|
||||
|
||||
@@ -17,7 +17,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("document.inspect", "Inspect Design", inspect).noundo().params("measure?: bool (include body measurements)"),
|
||||
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).noundo().params("sketch?: id|name (default active)"),
|
||||
CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"),
|
||||
CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params("body: name"),
|
||||
CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params(
|
||||
"body: name → per face: index, name (what face.delete, sheet.convert `face`, solid.shell … take in place of a point), exact, area, centroid (not always on the face), \
|
||||
plane_normal, point (a point on the face that picks it), cylinder? ({origin, axis, radius} of a cylindrical face)",
|
||||
),
|
||||
CommandSpec::new("model.threads", "List Threads", model_threads).noundo(),
|
||||
CommandSpec::new("material.assign", "Physical Material", physical_material)
|
||||
.at("SOLID", "MODIFY")
|
||||
@@ -26,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
|
||||
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
|
||||
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
|
||||
neither (or color: null): clear, so the face follows its body, the body its component, the component its material",
|
||||
pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (requires color/appearance and opacity 1); omitted/null uses legacy scalar shading. Neither appearance nor color (or color: null): clear, so the face follows its body, the body its component, the component its material",
|
||||
),
|
||||
CommandSpec::new("appearance.library", "Appearance Library", appearance_library)
|
||||
.noundo()
|
||||
@@ -252,7 +255,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)}))
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"name": s.doc.name, "units": s.doc.units, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
|
||||
"name": s.doc.name, "units": s.doc.units, "metadata": s.doc.metadata, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
|
||||
"active_sketch": s.active_sketch, "marker": marker,
|
||||
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
|
||||
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), "hidden_origin": s.visibility.hidden_origin,
|
||||
@@ -283,13 +286,21 @@ fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = body_of(&st, p, "model.faces")?;
|
||||
let names = solidcraft_doc::naming::face_names(b);
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let spots = solidcraft_doc::naming::face_spots(b);
|
||||
let faces: Vec<Value> = b
|
||||
.body
|
||||
.faces(tol)?
|
||||
.iter()
|
||||
.map(|f| {
|
||||
json!({"index": f.index, "name": names.get(f.index), "exact": solidcraft_doc::naming::face_name_exact(b, f.index),
|
||||
"area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal})
|
||||
let spot = spots.get(f.index).copied().flatten();
|
||||
let mut v = json!({"index": f.index, "name": names.get(f.index), "exact": solidcraft_doc::naming::face_name_exact(b, f.index),
|
||||
"area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal, "point": spot.map(|x| x.point)});
|
||||
if let (Some(solidcraft_kernel::FaceSurface::Cylinder { origin, axis, radius }), Some(o)) =
|
||||
(spot.and_then(|x| x.surface), v.as_object_mut())
|
||||
{
|
||||
o.insert("cylinder".into(), json!({"origin": origin, "axis": axis, "radius": radius}));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"body": b.name, "faces": faces}))
|
||||
@@ -341,7 +352,7 @@ pub(super) fn color_param(v: &Value) -> Option<[u8; 3]> {
|
||||
fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use solidcraft_doc::appearance::{FaceLook, Look, library};
|
||||
let cmd = "appearance.assign";
|
||||
let look: Option<Look> = match (str_(p, "appearance"), p.get("color")) {
|
||||
let mut look: Option<Look> = match (str_(p, "appearance"), p.get("color")) {
|
||||
// A colour with a name: that colour, called that (a name not in the library is fine).
|
||||
(Some(n), Some(v)) if !v.is_null() => {
|
||||
let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?;
|
||||
@@ -367,6 +378,11 @@ fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Look::custom(c, o))
|
||||
}
|
||||
};
|
||||
if let Some(l) = &mut look {
|
||||
attach_pbr(cmd, p, l)?;
|
||||
} else if p.get("pbr").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(cmd, "`pbr` requires a color or appearance"));
|
||||
}
|
||||
let bodies = string_list(p, "bodies");
|
||||
let st = s.model.state();
|
||||
if bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
@@ -466,7 +482,7 @@ fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"bodies": bodies,
|
||||
"faces": faces.iter().map(|(b, p)| json!({"body": b, "point": p})).collect::<Vec<_>>(),
|
||||
"components": comps,
|
||||
"appearance": look.as_ref().map(|l| json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})),
|
||||
"appearance": look.as_ref().map(look_json),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -477,7 +493,40 @@ fn dist_to(b: &solidcraft_doc::ModelBody, p: solidcraft_geom::Vec3) -> f64 {
|
||||
}
|
||||
|
||||
pub(super) fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
|
||||
json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})
|
||||
let mut value = json!({"name": l.name, "color": l.hex(), "opacity": l.opacity});
|
||||
if let Some(pbr) = l.pbr {
|
||||
value["pbr"] = json!(pbr);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pbr_appearance_tests.rs"]
|
||||
mod pbr_tests;
|
||||
|
||||
pub(super) fn attach_pbr(cmd: &str, params: &Value, look: &mut solidcraft_doc::appearance::Look) -> Result<()> {
|
||||
let Some(value) = params.get("pbr") else { return Ok(()) };
|
||||
if value.is_null() {
|
||||
look.pbr = None;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(opacity) = params.get("opacity")
|
||||
&& opacity.as_f64().filter(|v| v.is_finite() && *v == 1.0).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "opaque PBR requires numeric opacity 1"));
|
||||
}
|
||||
let object = value
|
||||
.as_object()
|
||||
.filter(|o| o.len() == 3 && o.keys().all(|k| matches!(k.as_str(), "metallic" | "roughness" | "dielectric_ior")))
|
||||
.ok_or_else(|| bad(cmd, "`pbr` requires exactly metallic, roughness and dielectric_ior"))?;
|
||||
let number = |key: &str| object.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("`pbr.{key}` must be a finite number")));
|
||||
let response = solidcraft_doc::pbr_appearance::PbrParameters::new(number("metallic")?, number("roughness")?, number("dielectric_ior")?)
|
||||
.map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if look.opacity != 1.0 {
|
||||
return Err(bad(cmd, "opaque PBR requires opacity 1; refraction/transmission is not supported"));
|
||||
}
|
||||
look.pbr = Some(response);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn appearance_library(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -524,7 +573,13 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let in_design: Vec<Value> = used
|
||||
.iter()
|
||||
.map(|(l, b, f, c)| json!({"name": l.name, "color": l.hex(), "opacity": l.opacity, "bodies": b, "faces": f, "components": c}))
|
||||
.map(|(l, b, f, c)| {
|
||||
let mut value = look_json(l);
|
||||
value["bodies"] = json!(b);
|
||||
value["faces"] = json!(f);
|
||||
value["components"] = json!(c);
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({
|
||||
"in_design": in_design,
|
||||
|
||||
+131
-46
@@ -3,7 +3,7 @@
|
||||
//! joint graph and moves the occurrences it places; the session re-solves after any edit too,
|
||||
//! so joints follow the geometry their origins snap to.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
@@ -20,15 +20,21 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
|
||||
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball; a, b: joint origins ({occurrence, face|circle|point: [x,y,z] in world} | origin name); \
|
||||
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool; offset?; angle?; name?",
|
||||
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool (default false: the origins are mated, \
|
||||
b's z against a's as two faces meet; true: b's z along a's, so two origins given at one point with the same z stay as they are); \
|
||||
offset?; angle?; name?; quiet?: bool (leave out dof and fixed)",
|
||||
),
|
||||
CommandSpec::new("joint.as_built", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
|
||||
"type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame",
|
||||
"type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame; \
|
||||
name?; limits?: [[min, max] | null…]; quiet?: bool",
|
||||
),
|
||||
CommandSpec::new("joint.rigid_group", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
|
||||
CommandSpec::new("joint.drive", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
|
||||
"joint: id|name; value | values: [angles in deg or expressions, distances] (clamped to the limits; the other joints of a \
|
||||
closed loop follow, and the motion stops where the loop can no longer close → stopped_by_loop)",
|
||||
"joint: id|name; value | values: [angles in deg or expressions, distances] (values not given keep theirs; clamped to the limits; the other joints of a \
|
||||
closed loop follow, and the motion stops where the loop can no longer close → stopped_by_loop); quiet?: bool (leave out dof \
|
||||
and fixed) → values: [{name, value, unit: deg|mm, limits}] as joint.list gives them; moved: whether any occurrence moved; \
|
||||
reason: why the joint can move nothing (suppressed, its origins lost, or its chain joined to nothing grounded → unconnected: \
|
||||
the occurrence named)",
|
||||
),
|
||||
CommandSpec::new("joint.motion_link", "Motion Link", motion_link)
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
@@ -37,7 +43,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.limits", "Joint Limits", limits).params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
|
||||
CommandSpec::new("joint.edit", "Edit Joint", edit).params("joint; type?, flip?, offset?, angle?, suppressed?, name?"),
|
||||
CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"),
|
||||
CommandSpec::new("joint.list", "List Joints", list).noundo().params("→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts"),
|
||||
CommandSpec::new("joint.list", "List Joints", list).noundo().params(
|
||||
"→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \
|
||||
(joint chains joined to nothing grounded: the solver places none of them, so driving them moves nothing)",
|
||||
),
|
||||
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) → 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)",
|
||||
@@ -64,13 +73,93 @@ pub fn resolve_holding(s: &mut Session, held: &joints::Held) -> joints::Solution
|
||||
sol
|
||||
}
|
||||
|
||||
fn sol_json(sol: &joints::Solution) -> Value {
|
||||
json!({
|
||||
/// A solve's answer; `quiet: true` in the command's params leaves out the per-occurrence lists.
|
||||
fn sol_json(sol: &joints::Solution, p: &Value) -> Value {
|
||||
let mut out = json!({
|
||||
"conflicts": sol.conflicts.iter().map(|(id, n, w)| json!({"joint": id, "name": n, "why": w})).collect::<Vec<_>>(),
|
||||
"errors": sol.errors.iter().map(|(id, n, w)| json!({"joint": id, "name": n, "why": w})).collect::<Vec<_>>(),
|
||||
"dof": sol.dof.iter().map(|(o, d)| json!({"occurrence": o, "dof": d})).collect::<Vec<_>>(),
|
||||
"fixed": sol.fixed,
|
||||
})
|
||||
});
|
||||
if !bool_(p, "quiet").unwrap_or(false) {
|
||||
out["dof"] = sol.dof.iter().map(|(o, d)| json!({"occurrence": o, "dof": d})).collect();
|
||||
out["fixed"] = json!(sol.fixed);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A joint's values as `joint.list` gives them: degrees for angles, mm for distances, each with
|
||||
/// its name and limits.
|
||||
pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
|
||||
j.kind
|
||||
.dofs()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let (v, angle) = (q.get(i).copied().unwrap_or(0.0), j.kind.is_angle(i));
|
||||
json!({"name": n, "value": if angle { v.to_degrees() } else { v }, "unit": if angle { "deg" } else { "mm" },
|
||||
"limits": j.limits.get(i).copied().flatten().map(|(a, b)| if angle { [a.to_degrees(), b.to_degrees()] } else { [a, b] })})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn occurrence_name(s: &Session, id: u64) -> String {
|
||||
s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
/// Why driving joint `id` moves nothing whatever its values (`None`: it places what it joins),
|
||||
/// with the occurrence that is joined to nothing grounded when that is the reason.
|
||||
pub(crate) fn cannot_move(s: &Session, sol: &joints::Solution, id: u64) -> Option<(String, Option<String>)> {
|
||||
let j = s.doc.assembly.joints.iter().find(|j| j.id == id)?;
|
||||
// Its value carries through motion links to joints that may move (a suppressed dial too).
|
||||
if s.doc.assembly.links.iter().any(|l| l.a == id || l.b == id) {
|
||||
return None;
|
||||
}
|
||||
if j.suppressed {
|
||||
return Some((format!("{} is suppressed", j.name), None));
|
||||
}
|
||||
if let Some((_, _, why)) = sol.errors.iter().find(|e| e.0 == id) {
|
||||
return Some((why.clone(), None));
|
||||
}
|
||||
// The solver places what it reaches from the root and grounded occurrences.
|
||||
let placed = |o: u64| o == 0 || sol.world.contains_key(&o);
|
||||
if placed(j.a.occurrence) || placed(j.b.occurrence) {
|
||||
return None;
|
||||
}
|
||||
let name = occurrence_name(s, j.a.occurrence);
|
||||
Some((format!("{name} is not connected to anything grounded (ground it, or join it to a grounded part)"), Some(name)))
|
||||
}
|
||||
|
||||
/// Joint chains joined to nothing grounded, which the solver places none of: each chain's
|
||||
/// occurrences and joints, by name.
|
||||
fn floating_chains(s: &Session, sol: &joints::Solution) -> Vec<Value> {
|
||||
let placed = |o: u64| o == 0 || sol.world.contains_key(&o);
|
||||
let errors: BTreeSet<u64> = sol.errors.iter().map(|e| e.0).collect();
|
||||
let mut next: BTreeMap<u64, Vec<(u64, &Joint)>> = BTreeMap::new();
|
||||
for j in s.doc.assembly.joints.iter().filter(|j| !j.suppressed && !errors.contains(&j.id)) {
|
||||
if !placed(j.a.occurrence) && !placed(j.b.occurrence) {
|
||||
next.entry(j.a.occurrence).or_default().push((j.b.occurrence, j));
|
||||
next.entry(j.b.occurrence).or_default().push((j.a.occurrence, j));
|
||||
}
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
for &start in next.keys() {
|
||||
if !seen.insert(start) {
|
||||
continue;
|
||||
}
|
||||
let (mut occs, mut js, mut todo) = (vec![start], BTreeSet::new(), vec![start]);
|
||||
while let Some(o) = todo.pop() {
|
||||
for (other, j) in next.get(&o).into_iter().flatten() {
|
||||
js.insert((j.id, j.name.as_str()));
|
||||
if seen.insert(*other) {
|
||||
occs.push(*other);
|
||||
todo.push(*other);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(json!({"occurrences": occs.iter().map(|o| occurrence_name(s, *o)).collect::<Vec<_>>(),
|
||||
"joints": js.iter().map(|(_, n)| *n).collect::<Vec<_>>()}));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn occurrence_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
|
||||
@@ -225,7 +314,7 @@ fn next_joint_id(s: &Session) -> u64 {
|
||||
s.doc.assembly.joints.iter().map(|j| j.id).max().unwrap_or(0) + 1
|
||||
}
|
||||
|
||||
fn add_joint(s: &mut Session, j: Joint) -> Result<Value> {
|
||||
fn add_joint(s: &mut Session, j: Joint, p: &Value) -> Result<Value> {
|
||||
if s.doc.assembly.joints.len() >= 10_000 {
|
||||
return Err(EngineError::Other("too many joints".into()));
|
||||
}
|
||||
@@ -238,7 +327,7 @@ fn add_joint(s: &mut Session, j: Joint) -> Result<Value> {
|
||||
s.visibility.hidden_joints.retain(|h| *h != id);
|
||||
s.doc_mut().assembly.joints.push(j);
|
||||
let sol = resolve(s);
|
||||
let mut out = sol_json(&sol);
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["joint"] = json!(id);
|
||||
out["name"] = json!(name);
|
||||
Ok(out)
|
||||
@@ -292,7 +381,7 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
angle,
|
||||
suppressed: false,
|
||||
};
|
||||
add_joint(s, j)
|
||||
add_joint(s, j, p)
|
||||
}
|
||||
|
||||
fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -325,7 +414,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
};
|
||||
add_joint(s, j)
|
||||
add_joint(s, j, p)
|
||||
}
|
||||
|
||||
fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -357,26 +446,32 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let doc = s.doc_mut();
|
||||
let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == id) else { return Err(bad(cmd, "joint")) };
|
||||
// Values not given keep theirs.
|
||||
if let Some(Value::Array(a)) = p.get("values") {
|
||||
for (i, v) in q.iter_mut().enumerate().skip(a.len()) {
|
||||
*v = j.values.get(i).copied().unwrap_or(*v);
|
||||
}
|
||||
}
|
||||
joints::clamp(j, &mut q);
|
||||
// Moved as far toward `q` as contact sets and closed loops allow. Values left out of
|
||||
// `values` are not driven: in a closed loop they follow.
|
||||
// Values not given (after `value`'s first, or past the end of `values`) keep theirs.
|
||||
let given = match p.get("values") {
|
||||
Some(Value::Array(a)) => a.len(),
|
||||
_ => 1,
|
||||
};
|
||||
for (i, v) in q.iter_mut().enumerate().skip(given) {
|
||||
*v = j.values.get(i).copied().unwrap_or(*v);
|
||||
}
|
||||
joints::clamp(j, &mut q);
|
||||
// Moved as far toward `q` as contact sets and closed loops allow. Values left out of
|
||||
// `values` are not driven: in a closed loop they follow.
|
||||
let before = super::motion::positions(s);
|
||||
let r = super::motion::drive_joint(s, id, q, given)?;
|
||||
let sol = joints::solve(&s.doc, &s.model.state());
|
||||
let mut out = sol_json(&sol);
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["joint"] = json!(id);
|
||||
out["values"] = json!(s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.values.clone()));
|
||||
out["values"] = json!(s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| named_values(j, &j.values)));
|
||||
out["stopped_by_contact"] = r["stopped_by_contact"].clone();
|
||||
out["stopped_by_loop"] = r["stopped_by_loop"].clone();
|
||||
out["moved"] = json!(super::motion::moved(&before, &super::motion::positions(s)).next().is_some());
|
||||
if let Some((why, unconnected)) = cannot_move(s, &sol, id) {
|
||||
out["reason"] = json!(why);
|
||||
if let Some(o) = unconnected {
|
||||
out["unconnected"] = json!(o);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -395,7 +490,7 @@ fn motion_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let offset = num(p, "offset").unwrap_or(0.0);
|
||||
s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset });
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol))
|
||||
Ok(sol_json(&sol, p))
|
||||
}
|
||||
|
||||
fn limits(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -422,7 +517,7 @@ fn limits(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
joints::clamp(j, &mut q);
|
||||
j.values = q;
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol))
|
||||
Ok(sol_json(&sol, p))
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -463,7 +558,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
j.name = n.to_string();
|
||||
}
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol))
|
||||
Ok(sol_json(&sol, p))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -492,12 +587,12 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
doc.assembly.joints.retain(|j| j.id != id);
|
||||
doc.assembly.links.retain(|l| l.a != id && l.b != id);
|
||||
let sol = resolve(s);
|
||||
let mut out = sol_json(&sol);
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["deleted"] = json!(id);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let sol = joints::solve(&s.doc, &st);
|
||||
let vals = joints::joint_values(&s.doc.assembly);
|
||||
@@ -507,18 +602,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
.joints
|
||||
.iter()
|
||||
.map(|j| {
|
||||
let q = vals.get(&j.id).cloned().unwrap_or_default();
|
||||
let named: Vec<Value> = j
|
||||
.kind
|
||||
.dofs()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let v = q.get(i).copied().unwrap_or(0.0);
|
||||
json!({"name": n, "value": if j.kind.is_angle(i) { v.to_degrees() } else { v }, "unit": if j.kind.is_angle(i) { "deg" } else { "mm" },
|
||||
"limits": j.limits.get(i).copied().flatten().map(|(a, b)| if j.kind.is_angle(i) { [a.to_degrees(), b.to_degrees()] } else { [a, b] })})
|
||||
})
|
||||
.collect();
|
||||
let named = named_values(j, vals.get(&j.id).map(Vec::as_slice).unwrap_or_default());
|
||||
let world = |o: &JointOrigin| s.doc.origin_world(&st, o).ok().map(|m| json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]]}));
|
||||
json!({"id": j.id, "name": j.name, "type": j.kind, "a": j.a.occurrence, "b": j.b.occurrence, "values": named,
|
||||
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "visible": s.joint_visible(j.id), "frame_a": world(&j.a), "frame_b": world(&j.b)})
|
||||
@@ -527,16 +611,17 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let links: Vec<Value> =
|
||||
s.doc.assembly.links.iter().map(|l| json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": l.ratio, "offset": l.offset})).collect();
|
||||
let origins: Vec<Value> = s.doc.assembly.origins.iter().map(|o| json!({"name": o.name, "occurrence": o.origin.occurrence})).collect();
|
||||
let mut out = sol_json(&sol);
|
||||
let mut out = sol_json(&sol, p);
|
||||
out["joints"] = json!(js);
|
||||
out["links"] = json!(links);
|
||||
out["origins"] = json!(origins);
|
||||
out["floating"] = json!(floating_chains(s, &sol));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn solve_cmd(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn solve_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol))
|
||||
Ok(sol_json(&sol, p))
|
||||
}
|
||||
|
||||
fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -95,7 +95,7 @@ fn slider_limits_clamp() {
|
||||
// B now sits on A.
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0)));
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 25}));
|
||||
assert_eq!(r["values"], json!([10.0]), "{r}");
|
||||
assert_eq!((&r["values"][0]["value"], &r["values"][0]["unit"]), (&json!(10.0), &json!("mm")), "{r}");
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 20.0)));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)));
|
||||
@@ -361,8 +361,8 @@ fn four_bar_follows_its_crank() {
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": v}));
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{v}°: {r}");
|
||||
assert!(r["stopped_by_loop"].is_null(), "{v}°: {r}");
|
||||
// The driven joint keeps exactly the value asked for.
|
||||
assert!((r["values"][0].as_f64().unwrap_or(f64::NAN) - v.to_radians()).abs() < 1e-12, "{r}");
|
||||
// The driven joint keeps exactly the value asked for, answered in degrees as asked.
|
||||
assert!((r["values"][0]["value"].as_f64().unwrap_or(f64::NAN) - v).abs() < 1e-9 && r["values"][0]["unit"] == "deg", "{r}");
|
||||
q = rocker_pin(v - 30.0, v, q);
|
||||
let th = (90.0 + v).to_radians();
|
||||
let p = Vec3::new(20.0 * th.cos(), 20.0 * th.sin(), 0.0);
|
||||
@@ -399,7 +399,7 @@ fn four_bar_rocker_stops_where_the_loop_cannot_close() {
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Rocker", "value": 100}));
|
||||
assert!(r["stopped_by_loop"].is_object(), "{r}");
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let reached = r["values"][0].as_f64().unwrap_or(f64::NAN).to_degrees();
|
||||
let reached = r["values"][0]["value"].as_f64().unwrap_or(f64::NAN);
|
||||
// |Q|² = 45² + 40² + 2·45·40·cos φ = 30².
|
||||
let limit = ((900.0f64 - 3625.0) / 3600.0).acos().to_degrees() - start;
|
||||
assert!(reached > limit - 5.0 && reached <= limit, "stopped at {reached}°, the limit is {limit}°");
|
||||
@@ -413,7 +413,7 @@ fn four_bar_rocker_stops_where_the_loop_cannot_close() {
|
||||
let (mut s, _, _) = four_bar();
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Rocker", "value": 360}));
|
||||
assert!(r["stopped_by_loop"].is_object(), "{r}");
|
||||
assert!(r["values"][0].as_f64().unwrap_or(f64::NAN).to_degrees() <= limit, "{r}");
|
||||
assert!(r["values"][0]["value"].as_f64().unwrap_or(f64::NAN) <= limit, "{r}");
|
||||
}
|
||||
|
||||
/// A loop value a motion link sets follows through the start of its link chain: a dial outside
|
||||
@@ -467,10 +467,9 @@ fn values_left_out_of_a_drive_follow_the_loop() {
|
||||
assert!(near(place(&s, "Plate", Vec3::ZERO), Vec3::new(10.0, 10.0, 0.0)), "{:?}", place(&s, "Plate", Vec3::ZERO));
|
||||
}
|
||||
|
||||
/// A slider-crank: crank 15 about the origin, rod 40, a piston sliding along X. The piston
|
||||
/// sits at a·cos θ + √(b² − (a·sin θ)²).
|
||||
#[test]
|
||||
fn slider_crank_moves_the_piston() {
|
||||
/// A slider-crank: crank 15 about the origin, rod 40, a piston sliding along X. Returns the crank
|
||||
/// pin and the wrist pin.
|
||||
fn slider_crank() -> (Session, Vec3, Vec3) {
|
||||
let mut s = Session::default();
|
||||
let (o, p0) = (Vec3::ZERO, Vec3::new(0.0, 15.0, 0.0));
|
||||
let s0 = Vec3::new((40.0f64 * 40.0 - 15.0 * 15.0).sqrt(), 0.0, 0.0);
|
||||
@@ -483,6 +482,13 @@ fn slider_crank_moves_the_piston() {
|
||||
pin(&mut s, "revolute", "Crank pin", "Crank", "Rod", p0, Vec3::Z);
|
||||
pin(&mut s, "revolute", "Wrist pin", "Rod", "Piston", s0, Vec3::Z);
|
||||
pin(&mut s, "slider", "Bore", "Ground", "Piston", s0, Vec3::X);
|
||||
(s, p0, s0)
|
||||
}
|
||||
|
||||
/// The piston sits at a·cos θ + √(b² − (a·sin θ)²).
|
||||
#[test]
|
||||
fn slider_crank_moves_the_piston() {
|
||||
let (mut s, p0, s0) = slider_crank();
|
||||
for v in [40.0, 135.0, 270.0] {
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": v}));
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{v}°: {r}");
|
||||
@@ -544,7 +550,7 @@ fn a_locked_loop_does_not_move() {
|
||||
run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "Ground", "b": "Rocker", "name": "Lock"}));
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": 360}));
|
||||
assert!(r["stopped_by_loop"].is_object(), "{r}");
|
||||
assert!(r["values"][0].as_f64().unwrap_or(f64::NAN).abs() < 1e-3, "{r}");
|
||||
assert!(r["values"][0]["value"].as_f64().unwrap_or(f64::NAN).to_radians().abs() < 1e-3, "{r}");
|
||||
}
|
||||
|
||||
/// A link that turns the rocker 100 times as far as its dial: a small turn of the dial is a full
|
||||
@@ -570,7 +576,7 @@ fn a_full_turn_stops_from_a_suppressed_dial_or_a_missing_value() {
|
||||
run(&mut s, "joint.motion_link", json!({"a": "Dial", "b": "Rocker", "ratio": 0.5}));
|
||||
run(&mut s, "joint.edit", json!({"joint": "Dial", "suppressed": true}));
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Dial", "value": 720}));
|
||||
assert!(r["stopped_by_loop"].is_object(), "{r}");
|
||||
assert!(r["stopped_by_loop"].is_object() && r["moved"] == true && r.get("reason").is_none(), "{r}");
|
||||
// Undriven, the suppressed dial still follows the rocker when the crank turns.
|
||||
run(&mut s, "joint.drive", json!({"joint": "Dial", "value": 0}));
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": 60}));
|
||||
@@ -721,6 +727,71 @@ fn a_clamped_link_is_followed_step_by_step() {
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
}
|
||||
|
||||
/// Each occurrence's degrees of freedom as `joint.list` reports them.
|
||||
fn dof_of(s: &mut Session, component: &str) -> u64 {
|
||||
let o = occ(s, component);
|
||||
let l = run(s, "joint.list", json!({}));
|
||||
l["dof"].as_array().into_iter().flatten().find(|d| d["occurrence"] == o).and_then(|d| d["dof"].as_u64()).unwrap_or(99)
|
||||
}
|
||||
|
||||
/// Loops take degrees of freedom away: a four-bar's moving parts have one between them (its
|
||||
/// mobility), a part hinged to the coupler one more, a slider-crank one, a parallelogram one, and a
|
||||
/// four-bar locked rigid none.
|
||||
#[test]
|
||||
fn loops_report_their_mobility() {
|
||||
let (mut s, p0, q0) = four_bar();
|
||||
for (part, want) in [("Ground", 0), ("Crank", 1), ("Coupler", 1), ("Rocker", 1)] {
|
||||
assert_eq!(dof_of(&mut s, part), want, "four-bar {part}");
|
||||
}
|
||||
// Still one after a drive moved it.
|
||||
run(&mut s, "joint.drive", json!({"joint": "Crank", "value": 70}));
|
||||
assert_eq!(dof_of(&mut s, "Coupler"), 1);
|
||||
// A flag hinged to the coupler half-way along: one more.
|
||||
let mid = (p0 + q0) * 0.5;
|
||||
part(&mut s, "Flag", [mid.x, mid.y, 5.0]);
|
||||
run(&mut s, "joint.as_built", json!({"type": "revolute", "a": "Coupler", "b": "Flag", "name": "Flag hinge"}));
|
||||
assert_eq!(dof_of(&mut s, "Flag"), 2);
|
||||
// Still two when a rotor elsewhere turns the flag's hinge through a motion link.
|
||||
part(&mut s, "Rotor", [100.0, 0.0, -5.0]);
|
||||
pin(&mut s, "revolute", "Rotor", "Ground", "Rotor", Vec3::new(100.0, 0.0, 0.0), Vec3::Z);
|
||||
run(&mut s, "joint.motion_link", json!({"a": "Rotor", "b": "Flag hinge", "ratio": 1}));
|
||||
assert_eq!(dof_of(&mut s, "Flag"), 2);
|
||||
// A rotor locked by a loop of its own leaves the flag only the mechanism's one.
|
||||
run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "Ground", "b": "Rotor", "name": "Rotor lock"}));
|
||||
assert_eq!(dof_of(&mut s, "Flag"), 1);
|
||||
// Locked: nothing moves.
|
||||
let (mut s, _, _) = four_bar();
|
||||
run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "Ground", "b": "Rocker", "name": "Lock"}));
|
||||
for part in ["Crank", "Coupler", "Rocker"] {
|
||||
assert_eq!(dof_of(&mut s, part), 0, "locked {part}");
|
||||
}
|
||||
// A rotor whose turn a motion link carries into the locked crank can't turn either.
|
||||
part(&mut s, "Rotor", [100.0, 0.0, -5.0]);
|
||||
pin(&mut s, "revolute", "Rotor", "Ground", "Rotor", Vec3::new(100.0, 0.0, 0.0), Vec3::Z);
|
||||
run(&mut s, "joint.motion_link", json!({"a": "Rotor", "b": "Crank", "ratio": 1}));
|
||||
assert_eq!(dof_of(&mut s, "Rotor"), 0);
|
||||
let (mut s, _, _) = slider_crank();
|
||||
for part in ["Crank", "Rod", "Piston"] {
|
||||
assert_eq!(dof_of(&mut s, part), 1, "slider-crank {part}");
|
||||
}
|
||||
// A parallelogram (crank and rocker 20, ground and coupler 45), away from its folded poses.
|
||||
let mut s = Session::default();
|
||||
let (o2, o4) = (Vec3::ZERO, Vec3::new(45.0, 0.0, 0.0));
|
||||
let (p, q) = (Vec3::new(12.0, 16.0, 0.0), Vec3::new(57.0, 16.0, 0.0));
|
||||
for (name, c) in [("Ground", o2), ("Crank", p), ("Coupler", (p + q) * 0.5), ("Rocker", q)] {
|
||||
part(&mut s, name, [c.x, c.y, -5.0]);
|
||||
}
|
||||
let g = occ(&s, "Ground");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
|
||||
pin(&mut s, "revolute", "Crank", "Ground", "Crank", o2, Vec3::Z);
|
||||
pin(&mut s, "revolute", "Crank pin", "Crank", "Coupler", p, Vec3::Z);
|
||||
pin(&mut s, "revolute", "Rocker pin", "Coupler", "Rocker", q, Vec3::Z);
|
||||
pin(&mut s, "revolute", "Rocker", "Ground", "Rocker", o4, Vec3::Z);
|
||||
for part in ["Crank", "Coupler", "Rocker"] {
|
||||
assert_eq!(dof_of(&mut s, part), 1, "parallelogram {part}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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-joint-visibility-{tag}-{}", std::process::id()));
|
||||
@@ -842,3 +913,128 @@ fn older_designs_show_every_joint_and_new_joints_start_shown() {
|
||||
assert!(s.joint_visible(again), "id {again} reused from {glue}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Dials R0…R4 on revolute joints to ground, linked R0 → R1 → … → `last` (ratio 1), the links
|
||||
/// stored from the end of the chain to its start.
|
||||
fn reverse_link_chain(s: &mut Session, last: &str) {
|
||||
for k in 0..5 {
|
||||
let x = 100.0 + 10.0 * f64::from(k);
|
||||
part(s, &format!("R{k}"), [x, 0.0, -5.0]);
|
||||
pin(s, "revolute", &format!("R{k}"), "Ground", &format!("R{k}"), Vec3::new(x, 0.0, 0.0), Vec3::Z);
|
||||
}
|
||||
for k in (0..5).rev() {
|
||||
let b = if k == 4 { last.to_string() } else { format!("R{}", k + 1) };
|
||||
run(s, "joint.motion_link", json!({"a": format!("R{k}"), "b": b, "ratio": 1}));
|
||||
}
|
||||
}
|
||||
|
||||
/// A motion link chain carries a value through all its links, however they are stored: five
|
||||
/// links stored from the end still turn the last joint with the first.
|
||||
#[test]
|
||||
fn a_link_chain_stored_backwards_carries_its_value_through() {
|
||||
let (mut s, _, _) = four_bar();
|
||||
part(&mut s, "Last", [200.0, 0.0, -5.0]);
|
||||
pin(&mut s, "revolute", "Last", "Ground", "Last", Vec3::new(200.0, 0.0, 0.0), Vec3::Z);
|
||||
reverse_link_chain(&mut s, "Last");
|
||||
run(&mut s, "joint.drive", json!({"joint": "R0", "value": 10}));
|
||||
let l = run(&mut s, "joint.list", json!({}));
|
||||
let last = l["joints"].as_array().into_iter().flatten().find(|j| j["name"] == "Last").map(|j| j["values"][0]["value"].clone());
|
||||
assert!((last.and_then(|v| v.as_f64()).unwrap_or(f64::NAN) - 10.0).abs() < 1e-9, "{l}");
|
||||
}
|
||||
|
||||
/// Driving the start of such a chain into a four-bar's crank turns the mechanism, and never
|
||||
/// reports its loop as opened.
|
||||
#[test]
|
||||
fn a_link_chain_stored_backwards_drives_a_loop() {
|
||||
let (mut s, _, q0) = four_bar();
|
||||
reverse_link_chain(&mut s, "Crank");
|
||||
for v in [1.0, 30.0] {
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "R0", "value": v}));
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{v}: {r}");
|
||||
assert!(r["stopped_by_loop"].is_null(), "{v}: {r}");
|
||||
assert!(near(place(&s, "Rocker", q0), rocker_pin(0.0, v, q0)), "{v}: {:?}", place(&s, "Rocker", q0));
|
||||
}
|
||||
}
|
||||
|
||||
/// A turret on a pedestal joined to nothing grounded (Rover v7's turret): the solver places
|
||||
/// neither, so driving the turret moves nothing. The drive, a motion study and joint.list say
|
||||
/// so, naming the pedestal, instead of answering as if it worked.
|
||||
#[test]
|
||||
fn a_turret_on_a_floating_pedestal_says_it_moves_nothing() {
|
||||
let mut s = Session::default();
|
||||
for (name, corner) in [("Chassis", [0.0, 0.0, 0.0]), ("Arm", [10.0, 0.0, 0.0]), ("Pedestal", [50.0, 0.0, 0.0]), ("Turret", [50.0, 0.0, 2.0])] {
|
||||
part(&mut s, name, corner);
|
||||
}
|
||||
let g = occ(&s, "Chassis");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
|
||||
pin(&mut s, "revolute", "Shoulder", "Chassis", "Arm", Vec3::new(10.0, 1.0, 1.0), Vec3::Y);
|
||||
pin(&mut s, "revolute", "Azimuth", "Pedestal", "Turret", Vec3::new(51.0, 1.0, 2.0), Vec3::Z);
|
||||
let corner = Vec3::new(52.0, 0.0, 2.0);
|
||||
let start = place(&s, "Turret", corner);
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Azimuth", "value": 30, "quiet": true}));
|
||||
assert_eq!((&r["moved"], &r["unconnected"]), (&json!(false), &json!("Pedestal:1")), "{r}");
|
||||
assert!(r["reason"].as_str().is_some_and(|w| w.starts_with("Pedestal:1 is not connected to anything grounded")), "{r}");
|
||||
assert!(r.get("dof").is_none() && r.get("fixed").is_none(), "quiet: {r}");
|
||||
// The value is set and answered in degrees, as asked; the turret stays where it was.
|
||||
assert!((r["values"][0]["value"].as_f64().unwrap_or(f64::NAN) - 30.0).abs() < 1e-9 && r["values"][0]["unit"] == "deg", "{r}");
|
||||
assert!(near(place(&s, "Turret", corner), start));
|
||||
let l = run(&mut s, "joint.list", json!({}));
|
||||
assert_eq!(l["floating"], json!([{"occurrences": ["Pedestal:1", "Turret:1"], "joints": ["Azimuth"]}]), "{l}");
|
||||
// A study keying both joints moves the arm and says the turret can't move.
|
||||
let keys = json!([{"joint": "Azimuth", "points": [[0, 0], [10, 90]]}, {"joint": "Shoulder", "points": [[0, 0], [10, 45]]}]);
|
||||
run(&mut s, "motion.study", json!({"name": "Scan", "steps": 10, "keys": keys}));
|
||||
let r = run(&mut s, "motion.play", json!({"study": "Scan", "step": 10, "quiet": true}));
|
||||
assert_eq!(r["moved"], true, "{r}");
|
||||
let unmoved = r["unmoved"].as_array().cloned().unwrap_or_default();
|
||||
assert!(unmoved.len() == 1 && unmoved[0]["name"] == "Azimuth" && unmoved[0]["reason"].as_str().is_some_and(|w| w.contains("Pedestal:1")), "{r}");
|
||||
// Quiet: only the arm, which moved, has its pose in the answer.
|
||||
assert_eq!(r["positions"].as_array().map(|p| p.iter().map(|x| x["occurrence"].clone()).collect::<Vec<_>>()), Some(vec![json!("Arm:1")]), "{r}");
|
||||
// Fixed to the chassis, the pedestal is placed and the turret turns.
|
||||
run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "Chassis", "b": "Pedestal", "name": "Mount"}));
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Azimuth", "value": 60}));
|
||||
assert!(r["moved"] == true && r.get("reason").is_none() && r.get("unconnected").is_none() && r["dof"].is_array(), "{r}");
|
||||
assert!(!near(place(&s, "Turret", corner), start));
|
||||
assert_eq!(run(&mut s, "joint.list", json!({}))["floating"], json!([]));
|
||||
}
|
||||
|
||||
/// `quiet: true` leaves the per-occurrence lists out of joint answers, and `moved` says whether
|
||||
/// a drive moved anything: driving a joint to where it is moves nothing, with no reason given.
|
||||
#[test]
|
||||
fn quiet_joint_answers_and_a_drive_to_where_it_is() {
|
||||
let mut s = two_cubes();
|
||||
let r =
|
||||
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Rail", "quiet": true}));
|
||||
assert!(r.get("dof").is_none() && r.get("fixed").is_none() && r["conflicts"].is_array(), "{r}");
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 5, "quiet": true}));
|
||||
assert!(r.get("dof").is_none() && r["moved"] == true, "{r}");
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 5}));
|
||||
assert!(r["dof"].is_array() && r["moved"] == false && r.get("reason").is_none(), "{r}");
|
||||
// A suppressed joint with no motion link moves nothing, and says why.
|
||||
run(&mut s, "joint.edit", json!({"joint": "Rail", "suppressed": true}));
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 8}));
|
||||
assert!(r["moved"] == false && r["reason"] == "Rail is suppressed" && r.get("unconnected").is_none(), "{r}");
|
||||
}
|
||||
|
||||
/// An as-built joint takes a name and limits like any other joint.
|
||||
#[test]
|
||||
fn as_built_takes_a_name_and_limits() {
|
||||
let mut s = two_cubes();
|
||||
let r = run(&mut s, "joint.as_built", json!({"type": "slider", "a": "A", "b": "B", "name": "Rail", "limits": [[0, 5]]}));
|
||||
assert_eq!(r["name"], "Rail", "{r}");
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 20}));
|
||||
assert_eq!((&r["values"][0]["value"], &r["values"][0]["limits"]), (&json!(5.0), &json!([0.0, 5.0])), "{r}");
|
||||
assert!(near(place(&s, "B", Vec3::new(20.0, 0.0, 0.0)), Vec3::new(20.0, 0.0, 5.0)), "{:?}", place(&s, "B", Vec3::new(20.0, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
/// `value` drives a joint's first value only: the others keep theirs, as with a shorter `values`.
|
||||
#[test]
|
||||
fn a_single_value_keeps_the_joints_other_values() {
|
||||
let mut s = two_cubes();
|
||||
run(&mut s, "joint.as_built", json!({"type": "cylindrical", "a": "A", "b": "B", "name": "Spindle"}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Spindle", "values": [30, 5]}));
|
||||
let at = |r: &Value| [0, 1].map(|i| r["values"][i]["value"].as_f64().unwrap_or(f64::NAN));
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Spindle", "value": 45}));
|
||||
assert!((at(&r)[0] - 45.0).abs() < 1e-9 && (at(&r)[1] - 5.0).abs() < 1e-9, "{r}");
|
||||
let r = run(&mut s, "joint.drive", json!({"joint": "Spindle", "values": [60]}));
|
||||
assert!((at(&r)[0] - 60.0).abs() < 1e-9 && (at(&r)[1] - 5.0).abs() < 1e-9, "{r}");
|
||||
}
|
||||
@@ -10,7 +10,7 @@ impl File {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
Self(
|
||||
std::env::temp_dir()
|
||||
solidcraft_io::vfs::temp_dir()
|
||||
.join(format!("solidcraft-catalog-{}-{}.json", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)))
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
@@ -218,3 +218,35 @@ fn catalog_inspection_export_and_no_clobber_do_not_touch_design_or_history() {
|
||||
assert!(s.execute(command, &json!({"path":file.0,"unknown":true})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossy_scalar_export_never_creates_or_replaces_a_file_or_changes_history() {
|
||||
let mut s = session();
|
||||
s.execute(
|
||||
"appearance.assign",
|
||||
&json!({"bodies":["Body1"],"color":[180,120,70],
|
||||
"pbr":{"metallic":1.0,"roughness":0.3,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.mark_saved();
|
||||
let doc = s.doc.clone();
|
||||
let history = (s.undo.len(), s.redo.len(), s.revision);
|
||||
for existing in [false, true] {
|
||||
let file = File::new();
|
||||
if existing {
|
||||
std::fs::write(&file.0, b"preserve existing library").unwrap();
|
||||
}
|
||||
for overwrite in [false, true] {
|
||||
let error = s.execute("library.export", &json!({"path":file.0,"overwrite":overwrite})).unwrap_err();
|
||||
assert!(error.to_string().contains("cannot preserve PBR"));
|
||||
if existing {
|
||||
assert_eq!(std::fs::read(&file.0).unwrap(), b"preserve existing library");
|
||||
} else {
|
||||
assert!(!std::path::Path::new(&file.0).exists());
|
||||
}
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
|
||||
assert!(!s.is_dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,10 @@ 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",
|
||||
"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), pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (opaque only, needs color/appearance): the default look. Optical edits do not change density. 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)",
|
||||
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity?, pbr? (as material.create; color: null clears the look). Omitted appearance/color retains the entire current 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)",
|
||||
@@ -60,6 +60,9 @@ fn density(cmd: &str, p: &Value) -> Result<f64> {
|
||||
/// 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>>> {
|
||||
if p.get("pbr").is_some() && str_(p, "appearance").is_none() && p.get("color").is_none_or(Value::is_null) {
|
||||
return Err(bad(cmd, "`pbr` requires an appearance or color"));
|
||||
}
|
||||
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,
|
||||
@@ -79,6 +82,7 @@ fn look(cmd: &str, p: &Value, material: &str) -> Result<Option<Option<Look>>> {
|
||||
if let Some(o) = opacity {
|
||||
l.opacity = o;
|
||||
}
|
||||
super::inspect::attach_pbr(cmd, p, &mut l)?;
|
||||
Ok(Some(Some(l)))
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ use crate::{
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("mesh.convert", "Convert Mesh", convert)
|
||||
.at("MESH", "MODIFY")
|
||||
.icon("import")
|
||||
.params("body: mesh body name; tolerance?: length expression (default 0.05 mm): reconstruct a closed constant-section mechanical mesh as editable planar and cylindrical faces, with a parametric timeline feature; name?"),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Design properties (#1015): `document.metadata` reads the design's `metadata` object, and
|
||||
//! `document.metadata.set` and `document.metadata.remove` change it, each in one undo step. The
|
||||
//! keys SolidCraft reads, and the checks a change gets, are `solidcraft_doc::metadata`'s; keys
|
||||
//! other tools wrote are kept as they are. The UI's Design Properties dialog, scripts and MCP
|
||||
//! agents all go through these commands.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_doc::metadata::{MAX_KEYS, WELL_KNOWN};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("document.metadata", "Design Properties", read).icon("settings").noundo().doc_only().params(
|
||||
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: project, part_number, revision, \
|
||||
author, organization, description and license, each its text or null (absent, or not text)",
|
||||
),
|
||||
CommandSpec::new("document.metadata.set", "Change Design Properties", set).icon("settings").doc_only().params(
|
||||
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys: project, part_number, \
|
||||
revision, author, organization, description, license (text; \"\" or null removes); another tool's data goes under its \
|
||||
reverse-DNS key such as com.example.tool (an object; null removes) → metadata, changed: [keys]",
|
||||
),
|
||||
CommandSpec::new("document.metadata.remove", "Remove Design Property", remove)
|
||||
.doc_only()
|
||||
.params("key, or keys: [keys] (any key, also one another tool wrote) → metadata, removed: [keys that were there]"),
|
||||
];
|
||||
|
||||
fn read(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let m = &s.doc.metadata;
|
||||
let properties: Map<String, Value> = WELL_KNOWN.iter().map(|k| (k.to_string(), m.text(k).map_or(Value::Null, |t| json!(t)))).collect();
|
||||
Ok(json!({"metadata": m, "properties": properties}))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "document.metadata.set";
|
||||
let entries: Vec<(&str, &Value)> = match (p.get("values"), p.get("key")) {
|
||||
(Some(Value::Object(values)), None) if values.len() <= MAX_KEYS => values.iter().map(|(k, v)| (k.as_str(), v)).collect(),
|
||||
(Some(Value::Object(_)), None) => return Err(bad(cmd, format!("`values` can hold at most {MAX_KEYS} keys"))),
|
||||
(None, Some(_)) => {
|
||||
let key = str_(p, "key").ok_or_else(|| bad(cmd, "`key` must be text"))?;
|
||||
let value = p.get("value").ok_or_else(|| bad(cmd, "`value` is required (null removes the key)"))?;
|
||||
vec![(key, value)]
|
||||
}
|
||||
_ => return Err(bad(cmd, "give `key` and `value`, or `values`: {key: value, …}")),
|
||||
};
|
||||
let mut next = s.doc.metadata.clone();
|
||||
let mut changed = Vec::new();
|
||||
for (key, value) in entries {
|
||||
if next.set(key, value.clone()).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
changed.push(key.to_string());
|
||||
}
|
||||
}
|
||||
// Only a real change touches the design (an undo step, unsaved changes).
|
||||
if next != s.doc.metadata {
|
||||
s.doc_mut().metadata = next;
|
||||
}
|
||||
Ok(json!({"metadata": s.doc.metadata, "changed": changed}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "document.metadata.remove";
|
||||
let keys: Vec<&str> = match (p.get("keys"), p.get("key")) {
|
||||
(Some(Value::Array(keys)), None) if keys.len() <= MAX_KEYS => {
|
||||
keys.iter().map(Value::as_str).collect::<Option<_>>().ok_or_else(|| bad(cmd, "`keys` must be a list of keys"))?
|
||||
}
|
||||
(None, Some(_)) => vec![str_(p, "key").ok_or_else(|| bad(cmd, "`key` must be text"))?],
|
||||
_ => return Err(bad(cmd, format!("give `key`, or `keys`: a list of at most {MAX_KEYS} keys"))),
|
||||
};
|
||||
let mut removed: Vec<&str> = Vec::new();
|
||||
for k in keys {
|
||||
if s.doc.metadata.get(k).is_some() && !removed.contains(&k) {
|
||||
removed.push(k);
|
||||
}
|
||||
}
|
||||
if !removed.is_empty() {
|
||||
let m = &mut s.doc_mut().metadata;
|
||||
for k in &removed {
|
||||
m.remove(k);
|
||||
}
|
||||
}
|
||||
Ok(json!({"metadata": s.doc.metadata, "removed": removed}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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 folder of its own for one test's files.
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-metadata-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_set_read_and_remove_in_one_undo_step_each() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
s.mark_saved();
|
||||
let undo = s.undo.len();
|
||||
let r = run(
|
||||
&mut s,
|
||||
"document.metadata.set",
|
||||
json!({"values": {"author": "Jane Doe", "part_number": "ACME-1042", "com.acme.shop": {"shop_id": 88123}}}),
|
||||
);
|
||||
assert_eq!(r["changed"], json!(["author", "com.acme.shop", "part_number"]));
|
||||
assert_eq!(s.undo.len(), undo + 1, "several keys, one undo step");
|
||||
assert!(s.is_dirty());
|
||||
let r = run(&mut s, "document.metadata", json!({}));
|
||||
assert_eq!(r["properties"]["author"], "Jane Doe");
|
||||
assert_eq!(r["properties"]["project"], Value::Null);
|
||||
assert_eq!(r["metadata"]["com.acme.shop"]["shop_id"], 88123);
|
||||
|
||||
// Setting what is already there changes nothing: no undo step.
|
||||
let r = run(&mut s, "document.metadata.set", json!({"key": "author", "value": "Jane Doe"}));
|
||||
assert_eq!(r["changed"], json!([]));
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
|
||||
let r = run(&mut s, "document.metadata.remove", json!({"keys": ["author", "author", "not.there"]}));
|
||||
assert_eq!(r["removed"], json!(["author"]));
|
||||
assert_eq!(s.doc.metadata.author(), None);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.metadata.author(), Some("Jane Doe"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.metadata.is_empty());
|
||||
assert!(!s.is_dirty(), "undone back to the saved design");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.metadata.part_number(), Some("ACME-1042"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_change_leaves_the_design_as_it_was() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.metadata.set", json!({"key": "author", "value": "Jane"}));
|
||||
let undo = s.undo.len();
|
||||
for p in [
|
||||
json!({"values": {"project": "P", "author": 42}}),
|
||||
json!({"values": {"project": "P", "colour": "red"}}),
|
||||
json!({"values": {"project": "P", "com.acme": "not an object"}}),
|
||||
json!({"key": "author"}),
|
||||
json!({"key": 7, "value": "x"}),
|
||||
json!({"key": "author", "value": "x", "values": {}}),
|
||||
json!({"values": [1, 2]}),
|
||||
json!({}),
|
||||
] {
|
||||
assert!(s.execute("document.metadata.set", &p).is_err(), "{p}");
|
||||
}
|
||||
for p in [json!({}), json!({"keys": "author"}), json!({"keys": [1]}), json!({"key": ["author"]})] {
|
||||
assert!(s.execute("document.metadata.remove", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert_eq!(s.doc.metadata.project(), None, "all or nothing");
|
||||
assert_eq!(s.doc.metadata.author(), Some("Jane"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_tools_keys_and_odd_values_survive_open_edit_and_save() {
|
||||
let dir = scratch("roundtrip");
|
||||
// A design another tool added metadata to by hand: a namespaced object with every kind of
|
||||
// JSON value, a well-known key of the wrong type, a key a newer SolidCraft might read, and
|
||||
// numbers no feature input could hold.
|
||||
let foreign = json!({
|
||||
"author": 42,
|
||||
"revision": "B",
|
||||
"com.acme.description": {"shop_id": 88123, "nested": {"list": [1, 2.5, null, true, "x", {"deep": []}]}, "big": 1.5e300},
|
||||
"org.printfarm": {"slicer_profile": "PETG-0.2"},
|
||||
"future_key": ["kept"],
|
||||
});
|
||||
let mut design: Value = serde_json::from_str(&solidcraft_doc::Document::new("Foreign").to_json()).unwrap();
|
||||
design["metadata"] = foreign.clone();
|
||||
let plain = dir.join("foreign.json");
|
||||
std::fs::write(&plain, serde_json::to_string_pretty(&design).unwrap()).unwrap();
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "doc.open", json!({"path": plain}));
|
||||
assert_eq!(serde_json::to_value(&s.doc.metadata).unwrap(), foreign, "opened as it was");
|
||||
assert_eq!(s.doc.metadata.author(), None, "a number is not an author");
|
||||
let r = run(&mut s, "document.metadata", json!({}));
|
||||
assert_eq!(r["properties"]["author"], Value::Null);
|
||||
assert_eq!(r["properties"]["revision"], "B");
|
||||
|
||||
run(&mut s, "document.metadata.set", json!({"key": "project", "value": "ACME enclosures"}));
|
||||
let mut expected = foreign.clone();
|
||||
expected["project"] = json!("ACME enclosures");
|
||||
for (file, format) in [("edited.json", "json"), ("edited.solidcraft", "container")] {
|
||||
let path = dir.join(file);
|
||||
run(&mut s, "file.save_as", json!({"path": path, "format": format}));
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "doc.open", json!({"path": path}));
|
||||
assert_eq!(serde_json::to_value(&reopened.doc.metadata).unwrap(), expected, "{format}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_without_properties_saves_as_before() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let before = solidcraft_io::write_design(&s.doc);
|
||||
run(&mut s, "document.metadata.set", json!({"key": "author", "value": "Jane"}));
|
||||
run(&mut s, "document.metadata.remove", json!({"key": "author"}));
|
||||
assert_eq!(solidcraft_io::write_design(&s.doc), before, "byte for byte");
|
||||
assert!(!String::from_utf8_lossy(&before).contains("\"metadata\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_carry_the_properties_and_never_other_tools_keys() {
|
||||
// `file.export` hands the design's properties to the writers; the mapping itself is
|
||||
// tested in solidcraft-io.
|
||||
let dir = scratch("export");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run(
|
||||
&mut s,
|
||||
"document.metadata.set",
|
||||
json!({"values": {
|
||||
"project": "ACME enclosures", "author": "Jane Doe", "organization": "ACME", "license": "CC-BY-4.0",
|
||||
"description": "Vented case\nfor a Pi", "part_number": "ACME-1042", "com.acme.secret": {"token": "SECRET-VALUE"},
|
||||
}}),
|
||||
);
|
||||
let text = |s: &mut Session, name: &str| {
|
||||
let path = dir.join(name);
|
||||
run(s, "file.export", json!({"path": path}));
|
||||
std::fs::read(&path).unwrap()
|
||||
};
|
||||
let step = String::from_utf8(text(&mut s, "part.step")).unwrap();
|
||||
assert!(step.contains("FILE_DESCRIPTION(('Vented case for a Pi'),'2;1');"), "{}", &step[..400]);
|
||||
assert!(step.contains("('Jane Doe'),('ACME')"), "{}", &step[..400]);
|
||||
let iges = String::from_utf8(text(&mut s, "part.igs")).unwrap();
|
||||
assert!(iges.contains("8HJane Doe") && iges.contains("4HACME"), "{}", &iges[..600]);
|
||||
// 3MF model metadata is checked where it is written (solidcraft-io); here: it exports.
|
||||
assert!(!text(&mut s, "part.3mf").is_empty());
|
||||
for out in [step.as_bytes(), iges.as_bytes()] {
|
||||
let found = |needle: &str| out.windows(needle.len()).any(|w| w == needle.as_bytes());
|
||||
assert!(!found("SECRET-VALUE") && !found("com.acme"), "another tool's data is never exported");
|
||||
}
|
||||
// An assembly's STEP gets the same header.
|
||||
run(&mut s, "component.create", json!({}));
|
||||
assert!(!s.doc.occurrences.is_empty());
|
||||
let assembly = String::from_utf8(text(&mut s, "assembly.step")).unwrap();
|
||||
assert!(assembly.contains("('Jane Doe'),('ACME')"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_lists_the_metadata() {
|
||||
let mut s = Session::default();
|
||||
assert_eq!(run(&mut s, "document.inspect", json!({}))["metadata"], json!({}));
|
||||
run(&mut s, "document.metadata.set", json!({"key": "revision", "value": "C"}));
|
||||
assert_eq!(run(&mut s, "document.inspect", json!({}))["metadata"], json!({"revision": "C"}));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ mod material_properties;
|
||||
mod materials;
|
||||
mod measure_sel;
|
||||
mod mesh;
|
||||
mod metadata;
|
||||
pub(crate) mod motion;
|
||||
mod parts;
|
||||
mod script;
|
||||
@@ -252,6 +253,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(script::COMMANDS.iter());
|
||||
v.extend(parameters::COMMANDS.iter());
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(metadata::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
v.extend(materials::COMMANDS.iter());
|
||||
v.extend(material_properties::COMMANDS.iter());
|
||||
@@ -267,3 +269,45 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
let all = command_specs();
|
||||
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
|
||||
}
|
||||
|
||||
/// The end of an unknown command's error: up to three ids like `id`, nearest first. Ids a few
|
||||
/// letters away (`solid.extrud` → `solid.extrude`) come before ids that end in the same word
|
||||
/// (`file.open` → `doc.open`). Empty when nothing is close.
|
||||
pub(crate) fn did_you_mean(id: &str) -> String {
|
||||
let id = id.to_ascii_lowercase();
|
||||
if id.len() > 64 {
|
||||
return String::new();
|
||||
}
|
||||
let last = |s: &str| s.rsplit('.').next().unwrap_or(s).to_string();
|
||||
let near = (id.chars().count() / 4).max(2);
|
||||
let mut found: Vec<(bool, usize, &str)> = command_specs()
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let d = edit_distance(&id, c.id);
|
||||
(d <= near || last(&id) == last(c.id)).then_some((d > near, d, c.id))
|
||||
})
|
||||
.collect();
|
||||
found.sort();
|
||||
let ids: Vec<String> = found.iter().take(3).map(|(_, _, c)| format!("`{c}`")).collect();
|
||||
match ids.as_slice() {
|
||||
[] => String::new(),
|
||||
[one] => format!(" (did you mean {one}?)"),
|
||||
[rest @ .., end] => format!(" (did you mean {} or {end}?)", rest.join(", ")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Levenshtein distance: the single-character edits that turn `a` into `b`.
|
||||
fn edit_distance(a: &str, b: &str) -> usize {
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
let mut row: Vec<usize> = (0..=b.len()).collect();
|
||||
for (i, ca) in a.chars().enumerate() {
|
||||
let mut prev = row[0];
|
||||
row[0] = i + 1;
|
||||
for (j, cb) in b.iter().enumerate() {
|
||||
let next = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(row[j + 1] + 1);
|
||||
prev = row[j + 1];
|
||||
row[j + 1] = next;
|
||||
}
|
||||
}
|
||||
row[b.len()]
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use solidcraft_geom::Vec3;
|
||||
use solidcraft_kernel::Body;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::joints::{joint_id, occurrence_id, resolve_holding, value};
|
||||
use super::joints::{cannot_move, joint_id, named_values, occurrence_id, resolve_holding, value};
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
@@ -37,8 +37,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \
|
||||
(values: degrees for angles, mm for distances, or expressions)",
|
||||
),
|
||||
CommandSpec::new("motion.play", "Play Motion Study", play)
|
||||
.params("study: id|name; step (0…steps) — sets the joints to the study's values there (contact sets stop them) → positions"),
|
||||
CommandSpec::new("motion.play", "Play Motion Study", play).params(
|
||||
"study: id|name; step (0…steps) — sets the joints to the study's values there (contact sets stop them); quiet?: bool \
|
||||
(positions of only the occurrences that moved) → values: [{joint, values: [{name, value, unit}]}], positions, moved: whether \
|
||||
any occurrence moved, unmoved: [{joint, name, reason}] (keyed joints that can move nothing, such as a chain joined to \
|
||||
nothing grounded)",
|
||||
),
|
||||
CommandSpec::new("motion.export", "Export Motion Study", export)
|
||||
.noundo()
|
||||
.params("study; samples? (default steps + 1); path? (.json or .csv) → occurrence positions per step (the design is not changed)"),
|
||||
@@ -258,8 +262,9 @@ fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
|
||||
/// Set joint values, stopping short where contact sets would collide or a closed loop could no
|
||||
/// longer close: the values are moved from where they are toward `want` as far as they go. The
|
||||
/// `given` values (joint, index) are driven; in a closed loop the others follow.
|
||||
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)], given: &BTreeSet<(u64, usize)>) -> Result<Value> {
|
||||
/// `given` values (joint, index) are driven; in a closed loop the others follow. Returns the
|
||||
/// answer and the last solve.
|
||||
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)], given: &BTreeSet<(u64, usize)>) -> Result<(Value, joints::Solution)> {
|
||||
let baseline = s.doc.clone();
|
||||
let start: Vec<(u64, Vec<f64>)> =
|
||||
want.iter().map(|(id, _)| (*id, s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone()).unwrap_or_default())).collect();
|
||||
@@ -347,15 +352,16 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)], given: &BTreeSe
|
||||
}
|
||||
let values: Vec<Value> = want
|
||||
.iter()
|
||||
.map(|(id, _)| json!({"joint": id, "values": s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone())}))
|
||||
.map(|(id, _)| json!({"joint": id, "values": s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| named_values(j, &j.values))}))
|
||||
.collect();
|
||||
Ok(json!({"values": values, "stopped_by_contact": stopped, "stopped_by_loop": stopped_by_loop, "conflicts": sol.conflicts.len()}))
|
||||
let out = json!({"values": values, "stopped_by_contact": stopped, "stopped_by_loop": stopped_by_loop, "conflicts": sol.conflicts.len()});
|
||||
Ok((out, sol))
|
||||
}
|
||||
|
||||
/// Drive one joint (joint.drive), honouring contact sets.
|
||||
pub fn drive_joint(s: &mut Session, id: u64, q: Vec<f64>, given: usize) -> Result<Value> {
|
||||
let given = (0..given.min(q.len())).map(|i| (id, i)).collect();
|
||||
drive_with_contact(s, &[(id, q)], &given)
|
||||
Ok(drive_with_contact(s, &[(id, q)], &given)?.0)
|
||||
}
|
||||
|
||||
fn enable_sets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -556,10 +562,16 @@ fn keyed(m: &MotionStudy) -> BTreeSet<(u64, usize)> {
|
||||
}
|
||||
|
||||
/// Where each occurrence is: (name, world transform).
|
||||
fn positions(s: &Session) -> Vec<(String, Mat)> {
|
||||
pub(super) fn positions(s: &Session) -> Vec<(String, Mat)> {
|
||||
s.doc.occurrences.iter().map(|o| (o.name.clone(), joints::occurrence_world(&s.doc, o.id))).collect()
|
||||
}
|
||||
|
||||
/// The occurrences placed differently `after` than `before` ([`positions`] of one design, so in
|
||||
/// the same order even where two share a name).
|
||||
pub(super) fn moved<'a>(before: &'a [(String, Mat)], after: &'a [(String, Mat)]) -> impl Iterator<Item = &'a (String, Mat)> {
|
||||
after.iter().zip(before).filter(|((_, a), (_, b))| a.iter().flatten().zip(b.iter().flatten()).any(|(x, y)| (x - y).abs() > 1e-9)).map(|(a, _)| a)
|
||||
}
|
||||
|
||||
fn pose_json(m: &Mat) -> Value {
|
||||
json!({"origin": [m[3][0], m[3][1], m[3][2]], "x": [m[0][0], m[0][1], m[0][2]], "y": [m[1][0], m[1][1], m[1][2]], "z": [m[2][0], m[2][1], m[2][2]]})
|
||||
}
|
||||
@@ -570,8 +582,22 @@ fn play(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m = s.doc.assembly.studies.get(i).cloned().ok_or_else(|| bad(cmd, "study"))?;
|
||||
let step = p.get("step").and_then(Value::as_f64).filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, "`step` must be a number"))?;
|
||||
let want = values_at(s, &m, step.clamp(0.0, f64::from(m.steps)));
|
||||
let mut out = drive_with_contact(s, &want, &keyed(&m))?;
|
||||
out["positions"] = positions(s).iter().map(|(n, t)| json!({"occurrence": n, "pose": pose_json(t)})).collect();
|
||||
let before = positions(s);
|
||||
let (mut out, sol) = drive_with_contact(s, &want, &keyed(&m))?;
|
||||
let after = positions(s);
|
||||
let moved: Vec<&(String, Mat)> = moved(&before, &after).collect();
|
||||
out["moved"] = json!(!moved.is_empty());
|
||||
let shown: Vec<&(String, Mat)> = if bool_(p, "quiet").unwrap_or(false) { moved } else { after.iter().collect() };
|
||||
out["positions"] = shown.iter().map(|(n, t)| json!({"occurrence": n, "pose": pose_json(t)})).collect();
|
||||
let joints: BTreeSet<u64> = m.keys.iter().map(|k| k.joint).collect();
|
||||
out["unmoved"] = joints
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let (why, _) = cannot_move(s, &sol, *id)?;
|
||||
let name = s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.name.clone());
|
||||
Some(json!({"joint": id, "name": name, "reason": why}))
|
||||
})
|
||||
.collect();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -589,7 +615,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for k in 0..samples {
|
||||
let step = f64::from(m.steps) * k as f64 / (samples - 1) as f64;
|
||||
let want = values_at(&t, &m, step);
|
||||
let r = drive_with_contact(&mut t, &want, &keyed(&m))?;
|
||||
let (r, _) = drive_with_contact(&mut t, &want, &keyed(&m))?;
|
||||
rows.push(json!({
|
||||
"step": step,
|
||||
"stopped_by_contact": r["stopped_by_contact"],
|
||||
|
||||
@@ -539,3 +539,28 @@ fn a_linked_design_brings_its_bodies_in_and_follows_edits_to_the_part_file() {
|
||||
near(&measured["total"]["center_of_mass"][0], 55.0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The compact list counts what a component holds and leaves out identity transforms; the full
|
||||
/// list stays as it was.
|
||||
#[test]
|
||||
fn compact_component_list_counts_and_skips_identity_transforms() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Block");
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "body_name": "Cube"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20, 0, 0]}))["occurrence"].as_u64().unwrap();
|
||||
let full = run(&mut s, "component.list", json!({}));
|
||||
let block = |l: &Value| l["components"].as_array().unwrap().iter().find(|c| c["id"] == component).unwrap().clone();
|
||||
assert_eq!(block(&full)["bodies"], json!(["Cube"]));
|
||||
assert!(block(&full)["occurrences"].as_array().unwrap().iter().all(|o| o["transform"].is_array()));
|
||||
let compact = run(&mut s, "component.list", json!({"compact": true}));
|
||||
let b = block(&compact);
|
||||
assert_eq!((&b["bodies"], &b["features"], &b["sketches"]), (&json!(1), &json!(1), &json!(0)), "{b}");
|
||||
let placed = |id: u64| b["occurrences"].as_array().unwrap().iter().find(|o| o["id"] == id).unwrap().clone();
|
||||
assert!(placed(first).get("transform").is_none(), "{b}");
|
||||
assert_eq!(placed(second)["transform"][3][0], 20.0, "{b}");
|
||||
let root = compact["components"].as_array().unwrap().iter().find(|c| c["id"] == 0).unwrap().clone();
|
||||
assert_eq!(root["children"], json!([first, second]), "{root}");
|
||||
// Body names on request.
|
||||
assert_eq!(block(&run(&mut s, "component.list", json!({"compact": true, "bodies": true})))["bodies"], json!(["Cube"]));
|
||||
}
|
||||
@@ -346,3 +346,59 @@ fn one_extrude_of_many_sketch_circles_scales_linearly() {
|
||||
panic!("32 circles, then 128, took {missed}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A bar with sawtooth teeth cut along one side (#400): the tooth profile drawn once, or all
|
||||
/// `teeth` in one sketch. Each tooth's slope starts on the previous tooth's wall.
|
||||
fn sawtooth_bar(teeth: usize, pattern: bool) -> (Session, Option<u64>) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Bar"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-10, 0], "p1": [10, 200]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Bar", "distance": 4, "operation": "new", "body_name": "Bar"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "YZ", "name": "Tooth"}));
|
||||
if pattern {
|
||||
run(&mut s, "sketch.line", json!({"points": [[9, 4.375], [14, 2.5], [14, 4.375]], "closed": true}));
|
||||
} else {
|
||||
let mut pts = vec![json!([9, 4.375])];
|
||||
for k in 0..teeth {
|
||||
pts.push(json!([14 + 4 * k, 2.5]));
|
||||
pts.push(json!([14 + 4 * k, if k + 1 == teeth { 4.375 } else { 4.0 }]));
|
||||
}
|
||||
run(&mut s, "sketch.line", json!({"points": pts, "closed": true}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Total thickness 16 mm keeps the sawtooth cut spanning 8 mm on each side of YZ.
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Tooth", "distance": 16, "direction": "symmetric", "operation": "cut", "name": "Tooth cut"}));
|
||||
let id = pattern.then(|| {
|
||||
let r = run(&mut s, "solid.pattern.rectangular", json!({"features": ["Tooth cut"], "dir1": [0, 1, 0], "count1": teeth, "spacing1": 4}));
|
||||
r["feature"].as_u64().unwrap()
|
||||
});
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// A feature pattern of a cut whose copies touch (#400): every copy went through the planar
|
||||
/// fallback, each left split edges the next copy had to cut through (2110 kernel edges for 333),
|
||||
/// and 40 teeth took 6.5 s where one sketch of all of them took 0.04 s. The pattern must build
|
||||
/// what the single cut builds, with no retry and no leftover topology.
|
||||
#[test]
|
||||
fn pattern_of_touching_cuts_needs_no_fallback() {
|
||||
let (mut s, id) = sawtooth_bar(40, true);
|
||||
let result = s.model.result(id.unwrap()).unwrap();
|
||||
assert!(result.error.is_none() && result.warning.is_none(), "{result:?}");
|
||||
assert!(result.boolean_diagnostics.events.is_empty(), "{:?}", result.boolean_diagnostics);
|
||||
let m = run(&mut s, "inspect.measure", json!({}));
|
||||
let (mut one, _) = sawtooth_bar(40, false);
|
||||
let single = run(&mut one, "inspect.measure", json!({}));
|
||||
// Bar less 40 teeth: in the bar each is a triangle 4 long and 1.5 deep (the slope leaves the
|
||||
// top at y = 10), cut 8 mm to each side.
|
||||
let want = 20.0 * 200.0 * 4.0 - 40.0 * 0.5 * 4.0 * 1.5 * 16.0;
|
||||
for m in [&m, &single] {
|
||||
let b = &m["bodies"][0];
|
||||
assert!(rel(b["volume_mm3"].as_f64().unwrap(), want) < 1e-9, "{b}");
|
||||
assert_eq!(b["kernel"]["edges"], b["edges"], "{b}");
|
||||
assert_eq!(b["kernel"]["vertices"], b["vertices"], "{b}");
|
||||
}
|
||||
for k in ["faces", "edges", "vertices"] {
|
||||
assert_eq!(m["bodies"][0][k], single["bodies"][0][k], "{k}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{Session, product_scene};
|
||||
|
||||
fn fixture() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"body_name":"Specimen","length":10,"width":20,"height":5})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format20_scalar_looks_upgrade_and_format21_setups_keep_their_meaning() {
|
||||
let mut s = fixture();
|
||||
s.execute("appearance.assign", &json!({"bodies":["Specimen"],"appearance":"Original legacy look","color":"#b47846","opacity":0.4})).unwrap();
|
||||
let before = serde_json::to_value(&s.doc.appearances).unwrap();
|
||||
let mut legacy = serde_json::to_value(&*s.doc).unwrap();
|
||||
legacy["format"] = json!("solidcraft/20");
|
||||
legacy.as_object_mut().unwrap().remove("render");
|
||||
let upgraded = crate::doc::Document::from_json(&legacy.to_string()).unwrap();
|
||||
assert_eq!(upgraded.format, crate::doc::format::format_string(crate::doc::format::FORMAT));
|
||||
assert_eq!(serde_json::to_value(&upgraded.appearances).unwrap(), before);
|
||||
assert!(upgraded.render.setups.is_empty());
|
||||
s.execute("render.settings.set", &json!({"name":"Studio","set":{"width":320,"height":240}})).unwrap();
|
||||
let mut saved: serde_json::Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
saved["format"] = json!("solidcraft/21");
|
||||
let d = crate::doc::Document::from_json(&saved.to_string()).unwrap();
|
||||
assert_eq!(d.format, crate::doc::format::format_string(crate::doc::format::FORMAT));
|
||||
assert_eq!(serde_json::to_value(&d.appearances).unwrap(), before);
|
||||
assert!(d.appearances.bodies["Specimen"].pbr.is_none());
|
||||
assert_eq!(d.render.setups["Studio"].width, 320);
|
||||
for version in [crate::doc::format::FORMAT + 1, crate::doc::format::FORMAT + 2] {
|
||||
saved["format"] = json!(format!("solidcraft/{version}"));
|
||||
assert!(crate::doc::Document::from_json(&saved.to_string()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_response_preserves_geometry_mass_undo_and_cold_reopening() {
|
||||
let mut s = fixture();
|
||||
let before = product_scene::extract(&s).unwrap();
|
||||
let mass = s.execute("component.properties", &json!({})).unwrap();
|
||||
s.execute(
|
||||
"appearance.assign",
|
||||
&json!({"bodies":["Specimen"],"color":"#b47846","appearance":"Original opaque test",
|
||||
"pbr":{"metallic":1.0,"roughness":0.3,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
let after = product_scene::extract(&s).unwrap();
|
||||
assert!(Arc::ptr_eq(&before.meshes[0].mesh, &after.meshes[0].mesh));
|
||||
assert_eq!(s.execute("component.properties", &json!({})).unwrap(), mass);
|
||||
assert!(after.meshes[0].surface.is_some());
|
||||
assert_eq!(after.required_features(), vec![crate::render::RenderFeature::Pbr]);
|
||||
let saved = s.doc.to_json();
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&saved).unwrap()["format"], crate::doc::format::format_string(crate::doc::format::FORMAT));
|
||||
let look = &s.doc.appearances.bodies["Specimen"];
|
||||
assert_eq!(crate::doc::config::appearance_cell(&serde_json::to_string(look).unwrap()).unwrap().as_ref(), Some(look));
|
||||
let list = s.execute("appearance.list", &json!({})).unwrap();
|
||||
assert_eq!(list["in_design"][0]["pbr"]["metallic"], 1.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(product_scene::extract(&s).unwrap().meshes[0].surface.is_none());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(product_scene::extract(&s).unwrap().meshes[0].surface.is_some());
|
||||
let mut reopened = Session::new(crate::doc::Document::from_json(&saved).unwrap());
|
||||
assert_eq!(product_scene::extract(&reopened).unwrap().meshes[0].surface, after.meshes[0].surface);
|
||||
assert_eq!(reopened.execute("component.properties", &json!({})).unwrap(), mass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_optical_requests_refuse_atomically_and_scalar_export_cannot_lose_them() {
|
||||
let mut s = fixture();
|
||||
let doc = s.doc.clone();
|
||||
let history = (s.undo.len(), s.redo.len(), s.revision);
|
||||
for params in [
|
||||
json!({"bodies":["Specimen"],"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}),
|
||||
json!({"bodies":["Specimen"],"color":"#ffffff","pbr":{"metallic":0,"roughness":0,"dielectric_ior":1.5}}),
|
||||
json!({"bodies":["Specimen"],"color":"#ffffff","opacity":0.5,"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}),
|
||||
json!({"bodies":["Specimen"],"appearance":"Named","color":"#ffffff","opacity":"invalid","pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}),
|
||||
json!({"bodies":["Specimen"],"appearance":"Named","color":"#ffffff","opacity":2,"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}),
|
||||
json!({"bodies":["Specimen"],"color":"#ffffff","pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5,"transmission":1}}),
|
||||
] {
|
||||
assert!(s.execute("appearance.assign", ¶ms).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
|
||||
}
|
||||
s.execute(
|
||||
"appearance.assign",
|
||||
&json!({"bodies":["Specimen"],"color":"#ffffff",
|
||||
"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default()).is_err());
|
||||
let mut invalid: serde_json::Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
invalid["appearances"]["bodies"]["Specimen"]["opacity"] = json!(0.5);
|
||||
assert!(crate::doc::Document::from_json(&invalid.to_string()).is_err());
|
||||
invalid["appearances"]["bodies"]["Specimen"]["opacity"] = json!(1.0);
|
||||
invalid["appearances"]["bodies"]["Specimen"]["pbr"]["roughness"] = json!(0.0);
|
||||
assert!(crate::doc::Document::from_json(&invalid.to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_default_look_and_face_override_keep_independent_optical_meaning() {
|
||||
let mut s = fixture();
|
||||
s.execute(
|
||||
"material.create",
|
||||
&json!({"name":"Original density fixture","density_g_cm3":0.7,"color":"#b47846",
|
||||
"pbr":{"metallic":0,"roughness":0.4,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("material.assign", &json!({"bodies":["Specimen"],"material":"Original density fixture"})).unwrap();
|
||||
let scene = product_scene::extract(&s).unwrap();
|
||||
assert!(scene.meshes[0].surface.is_some());
|
||||
s.execute("appearance.assign", &json!({"faces":[[5,10,5]],"body":"Specimen","color":"#0080a0"})).unwrap();
|
||||
let scene = product_scene::extract(&s).unwrap();
|
||||
assert!(scene.meshes[0].surface.is_some());
|
||||
assert!(scene.meshes[0].faces.iter().any(|f| f.color == [0, 128, 160] && f.surface.is_none()));
|
||||
assert_eq!(s.doc.user_materials[0].density, 0.7);
|
||||
assert!(solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_component_copy_keeps_its_opaque_response_after_source_edits_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
let part = s.execute("component.create", &json!({"name":"Part"})).unwrap()["component"].as_u64().unwrap();
|
||||
s.execute("solid.box", &json!({"body_name":"Specimen","length":10,"width":20,"height":5})).unwrap();
|
||||
s.execute(
|
||||
"appearance.assign",
|
||||
&json!({"components":["Part"],"color":"#b47846",
|
||||
"pbr":{"metallic":1,"roughness":0.3,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("component.activate", &json!({"component":0})).unwrap();
|
||||
let copied = s.execute("component.paste_new", &json!({"component":part,"translate":[30,0,0]})).unwrap()["component"].as_u64().unwrap();
|
||||
let original = s.doc.appearances.components[&part].clone();
|
||||
assert_eq!(s.doc.appearances.components[&copied], original);
|
||||
s.execute(
|
||||
"appearance.assign",
|
||||
&json!({"components":["Part"],"color":"#b47846",
|
||||
"pbr":{"metallic":1,"roughness":0.8,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(s.doc.appearances.components[&part], original);
|
||||
assert_eq!(s.doc.appearances.components[&copied], original);
|
||||
let reopened = crate::doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.appearances.components[&copied], original);
|
||||
}
|
||||
@@ -1,21 +1,122 @@
|
||||
//! Render capability discovery. No rendering, host probing or document mutation occurs here.
|
||||
//! Render capability discovery and persistent intent. No rendering or host probing.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::render_settings::{RenderSettings, RenderSetup};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("render.backends", "Render Backends", backends)
|
||||
mod jobs;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).noundo().doc_only()
|
||||
.params("setup?: saved name OR settings?: full RenderSetup; transparent_background?: false; replace?: false. Validates the installed image host and bounded captured product scene without starting work. Stale geometry is rejected. See docs/render-jobs.md"),
|
||||
CommandSpec::new("render.start", "Render Image", jobs::start).noundo().doc_only().at("RENDER", "IMAGE").icon("box")
|
||||
.params("Same request as render.preflight. Captures an immutable product scene and queues a bounded image job. Returns its session-scoped job identity, captured scene_generation and progress. Does not change the design. See docs/render-jobs.md"),
|
||||
CommandSpec::new("render.status", "Render Progress", jobs::status).noundo().doc_only()
|
||||
.params("job: {session, serial, generation}. Non-blocking progress, error and completed frame metadata; no image bytes and no rendering in this call."),
|
||||
CommandSpec::new("render.cancel", "Cancel Render", jobs::cancel).noundo().doc_only()
|
||||
.params("job: {session, serial, generation}. Requests cooperative cancellation; completed jobs remain completed. Late cancelled frames cannot be published."),
|
||||
CommandSpec::new("render.export", "Export Render Image", jobs::export).noundo().doc_only()
|
||||
.params("job: {session, serial, generation}, path: PNG destination, overwrite?: false. Exports a retained completed image atomically; an existing destination is preserved on failure. See docs/render-jobs.md"),
|
||||
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")];
|
||||
.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"),
|
||||
CommandSpec::new("render.settings.get", "Render Settings", get_settings).noundo().doc_only()
|
||||
.params("name?: saved setup. Without name returns {setups, defaults}; with name returns {name, setup}. Does not evaluate the model or apply a camera. See docs/render-settings.md"),
|
||||
CommandSpec::new("render.settings.set", "Save Render Settings", set_settings).doc_only()
|
||||
.params("name: setup name, set: object of fields to replace (backend, mode, camera, width, height, format, exposure_ev, environment_rotation, environment_intensity, background). Creates from defaults, validates atomically, one undo step; identical edits are no-ops. See docs/render-settings.md"),
|
||||
CommandSpec::new("render.scene", "Inspect Render Scene", scene).noundo().at("RENDER", "SCENE").icon("box")
|
||||
.params("No parameters. Authoritative product-scene metadata, without modelling overlays or mesh payloads. Explicitly settles stale geometry; does not render an image. See docs/render-workspace.md"),
|
||||
CommandSpec::new("render.settings.apply", "Apply Render Camera", apply_settings).noundo().doc_only()
|
||||
.params("name: saved setup. Applies its existing Camera snapshot including roll/up as view state, without modifying the document or starting a render."),
|
||||
];
|
||||
|
||||
fn backends(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
fn scene(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if !params.as_object().is_some_and(|p| p.is_empty()) {
|
||||
return Err(bad("render.scene", "expected an empty parameter object"));
|
||||
}
|
||||
let scene = crate::product_scene::extract(s)?;
|
||||
let bodies: Vec<_> = scene
|
||||
.meshes
|
||||
.iter()
|
||||
.map(|m| json!({"name":m.name,"triangles":m.mesh.triangles.len(),"color":m.color,"opacity":m.opacity,"face_appearances":m.faces.len()}))
|
||||
.collect();
|
||||
Ok(
|
||||
json!({"bodies":bodies,"bounds":if scene.bounds.is_empty() { Value::Null } else { json!({"min":scene.bounds.min,"max":scene.bounds.max}) },"overlays":false}),
|
||||
)
|
||||
}
|
||||
|
||||
fn apply_settings(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let request: GetSettings = serde_json::from_value(params.clone()).map_err(|e| bad("render.settings.apply", e.to_string()))?;
|
||||
let name = request.name.ok_or_else(|| bad("render.settings.apply", "`name` is required"))?;
|
||||
RenderSettings::validate_name(&name).map_err(|e| bad("render.settings.apply", e.to_string()))?;
|
||||
let setup = s.doc.render.setups.get(&name).ok_or_else(|| bad("render.settings.apply", "no saved render setup with that name"))?;
|
||||
setup.validate().map_err(|e| bad("render.settings.apply", e.to_string()))?;
|
||||
s.camera = Some(setup.camera);
|
||||
Ok(json!({"name":name,"camera":setup.camera}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct GetSettings {
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
fn get_settings(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let cmd = "render.settings.get";
|
||||
let request: GetSettings = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
match request.name {
|
||||
None => Ok(json!({"setups": s.doc.render.setups, "defaults": RenderSetup::default()})),
|
||||
Some(name) => {
|
||||
RenderSettings::validate_name(&name).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let setup = s.doc.render.setups.get(&name).ok_or_else(|| bad(cmd, "no saved render setup with that name"))?;
|
||||
Ok(json!({"name": name, "setup": setup}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SetSettings {
|
||||
name: String,
|
||||
set: Value,
|
||||
}
|
||||
|
||||
fn set_settings(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let cmd = "render.settings.set";
|
||||
let request: SetSettings = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
RenderSettings::validate_name(&request.name).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let patch = request.set.as_object().filter(|p| p.len() <= 10).ok_or_else(|| bad(cmd, "`set` must be an object with at most 10 fields"))?;
|
||||
let current = s.doc.render.setups.get(&request.name).cloned().unwrap_or_default();
|
||||
let mut value = serde_json::to_value(¤t).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let object = value.as_object_mut().ok_or_else(|| bad(cmd, "invalid render setup"))?;
|
||||
for (key, value) in patch {
|
||||
object.insert(key.clone(), value.clone());
|
||||
}
|
||||
let setup: RenderSetup = serde_json::from_value(value).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
setup.validate().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let reply = json!({"name": request.name, "setup": setup});
|
||||
if s.doc.render.setups.get(&request.name) == Some(&setup) {
|
||||
return Ok(reply);
|
||||
}
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
settings.setups.insert(request.name, setup);
|
||||
settings.validate().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
fn backends(s: &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()]}))
|
||||
Ok(json!({"schema_version": 1, "default_backend": "builtin", "backends": [s.render_backend()]}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -89,3 +190,9 @@ mod tests {
|
||||
assert_eq!(s.evaluations, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod settings_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod job_tests;
|
||||
@@ -0,0 +1,219 @@
|
||||
use super::*;
|
||||
use solidcraft_render::job::{CpuHost, JobId};
|
||||
|
||||
fn session() -> (Session, CpuHost) {
|
||||
let host = CpuHost::cooperative();
|
||||
let mut s = Session::default();
|
||||
s.set_render_job_host(Some(host.clone())).unwrap();
|
||||
(s, host)
|
||||
}
|
||||
|
||||
fn request() -> Value {
|
||||
json!({"settings":{"width":16,"height":16}})
|
||||
}
|
||||
fn start(s: &mut Session) -> JobId {
|
||||
serde_json::from_value(s.execute("render.start", &request()).unwrap()["job"].clone()).unwrap()
|
||||
}
|
||||
fn finish(host: &CpuHost) {
|
||||
for _ in 0..1000 {
|
||||
if !host.pump(4096).unwrap() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("cooperative job did not finish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_commands_preserve_document_history_and_evaluation() {
|
||||
let (mut s, host) = session();
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.evaluations, s.is_dirty(), s.undo.len(), s.redo.len());
|
||||
s.execute("render.preflight", &request()).unwrap();
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
let id = start(&mut s);
|
||||
assert_eq!(s.execute("render.status", &json!({"job":id})).unwrap()["progress"]["phase"], "queued");
|
||||
finish(&host);
|
||||
let status = s.execute("render.status", &json!({"job":id})).unwrap();
|
||||
assert_eq!(status["progress"]["phase"], "completed");
|
||||
assert_eq!(status["frame"]["job"], json!(id));
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.revision, s.evaluations, s.is_dirty(), s.undo.len(), s.redo.len()));
|
||||
assert_eq!(s.execute("render.cancel", &json!({"job":id})).unwrap()["progress"]["phase"], "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identities_are_isolated_across_documents_scratch_and_new() {
|
||||
let (mut a, host) = session();
|
||||
let mut b = Session::default();
|
||||
b.set_render_job_host(Some(host.clone())).unwrap();
|
||||
let a_id = start(&mut a);
|
||||
let b_id = start(&mut b);
|
||||
assert_eq!(a_id.serial, b_id.serial);
|
||||
assert_ne!(a_id.session, b_id.session);
|
||||
assert!(b.execute("render.cancel", &json!({"job":a_id})).is_err());
|
||||
assert!(a.scratch().execute("render.status", &json!({"job":a_id})).is_err());
|
||||
a.execute("file.new", &json!({})).unwrap();
|
||||
assert!(a.render_job_host().unwrap().same_host(&host));
|
||||
assert!(a.execute("render.status", &json!({"job":a_id})).is_err());
|
||||
finish(&host);
|
||||
assert_eq!(b.execute("render.status", &json!({"job":b_id})).unwrap()["progress"]["phase"], "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_cancellation_and_replacement_keep_the_last_successful_image() {
|
||||
let (mut s, host) = session();
|
||||
let good = start(&mut s);
|
||||
finish(&host);
|
||||
s.execute("render.status", &json!({"job":good})).unwrap();
|
||||
let a = start(&mut s);
|
||||
host.pump(1).unwrap();
|
||||
start(&mut s);
|
||||
assert!(s.execute("render.start", &request()).is_err());
|
||||
s.execute("render.cancel", &json!({"job":a})).unwrap();
|
||||
let replacement = s.execute("render.start", &json!({"settings":{"width":16,"height":16},"replace":true})).unwrap();
|
||||
s.execute("render.cancel", &json!({"job":replacement["job"]})).unwrap();
|
||||
finish(&host);
|
||||
assert!(s.render_jobs.frame(good).is_ok());
|
||||
assert_eq!(s.execute("render.status", &json!({"job":a})).unwrap()["progress"]["phase"], "cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_intent_is_rejected_and_export_preserves_existing_files() {
|
||||
let (mut s, host) = session();
|
||||
for params in [
|
||||
json!({"settings":{"mode":"final"}}),
|
||||
json!({"settings":{"format":"exr"}}),
|
||||
json!({"settings":{"backend":"missing"}}),
|
||||
json!({"settings":{"exposure_ev":1}}),
|
||||
json!({"unknown":true}),
|
||||
] {
|
||||
assert!(s.execute("render.preflight", ¶ms).is_err());
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
let id = start(&mut s);
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-job-{}-{}.png", std::process::id(), id.session));
|
||||
assert!(s.execute("render.export", &json!({"job":id,"path":path})).is_err());
|
||||
finish(&host);
|
||||
let missing = path.with_file_name("solidcraft-render-missing-directory").join("image.png");
|
||||
assert!(s.execute("render.export", &json!({"job":id,"path":missing})).is_err());
|
||||
assert!(s.render_jobs.frame(id).is_ok());
|
||||
s.execute("render.export", &json!({"job":id,"path":path})).unwrap();
|
||||
let png = std::fs::read(&path).unwrap();
|
||||
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
|
||||
assert!(s.execute("render.export", &json!({"job":id,"path":path})).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), png);
|
||||
s.execute("render.export", &json!({"job":id,"path":path,"overwrite":true})).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), png);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_close_and_host_replacement_cancel_work_and_reject_old_ids() {
|
||||
let (mut s, host) = session();
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-job-open-{}.solidcraft", std::process::id()));
|
||||
s.execute("file.save", &json!({"path":path})).unwrap();
|
||||
let id = start(&mut s);
|
||||
let handle = s.render_job_handle(id).unwrap();
|
||||
s.execute("doc.open", &json!({"path":path})).unwrap();
|
||||
assert_eq!(handle.status().unwrap().progress.phase, solidcraft_render::job::Phase::Cancelled);
|
||||
assert!(s.execute("render.status", &json!({"job":id})).is_err());
|
||||
assert!(s.render_job_host().unwrap().same_host(&host));
|
||||
let id = start(&mut s);
|
||||
let handle = s.render_job_handle(id).unwrap();
|
||||
s.replace_design(Session::default());
|
||||
assert_eq!(handle.status().unwrap().progress.phase, solidcraft_render::job::Phase::Cancelled);
|
||||
assert!(s.execute("render.status", &json!({"job":id})).is_err());
|
||||
assert!(s.render_job_host().unwrap().same_host(&host));
|
||||
let id = start(&mut s);
|
||||
let handle = s.render_job_handle(id).unwrap();
|
||||
let replacement = CpuHost::cooperative();
|
||||
s.set_render_job_host(Some(replacement.clone())).unwrap();
|
||||
assert_eq!(handle.status().unwrap().progress.phase, solidcraft_render::job::Phase::Cancelled);
|
||||
assert!(s.execute("render.status", &json!({"job":id})).is_err());
|
||||
let id = start(&mut s);
|
||||
let handle = s.render_job_handle(id).unwrap();
|
||||
drop(s);
|
||||
assert_eq!(handle.status().unwrap().progress.phase, solidcraft_render::job::Phase::Cancelled);
|
||||
finish(&host);
|
||||
finish(&replacement);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_image_is_immutable_across_edit_and_undo_and_stale_preflight_fails() {
|
||||
let (mut s, host) = session();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
let revision = s.revision;
|
||||
let id = start(&mut s);
|
||||
s.execute("solid.box", &json!({"length":3,"width":4,"height":5})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
finish(&host);
|
||||
let status = s.execute("render.status", &json!({"job":id})).unwrap();
|
||||
assert_eq!(status["scene_generation"], revision);
|
||||
assert_eq!(status["progress"]["phase"], "completed");
|
||||
assert_ne!(s.revision, revision);
|
||||
// Reuse the real live-gesture regression rather than manufacturing model state.
|
||||
for (command, params) in [
|
||||
("sketch.create", json!({"plane":"XY"})),
|
||||
("sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[25,20]})),
|
||||
("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(command, ¶ms).unwrap();
|
||||
}
|
||||
assert!(s.model.is_stale());
|
||||
let evaluations = s.evaluations;
|
||||
assert!(s.execute("render.preflight", &request()).is_err());
|
||||
assert!(s.execute("render.start", &request()).is_err());
|
||||
assert!(s.model.is_stale());
|
||||
assert_eq!(s.evaluations, evaluations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_start_and_cancel_measure_cached_capture_without_timing_assertions() {
|
||||
use std::time::{Duration, Instant};
|
||||
let mut s = Session::default();
|
||||
s.set_render_job_host(Some(CpuHost::for_platform().unwrap())).unwrap();
|
||||
s.execute("solid.box", &json!({"length":40,"width":30,"height":20})).unwrap();
|
||||
s.execute("render.scene", &json!({})).unwrap();
|
||||
let before = Instant::now();
|
||||
let reply = s.execute("render.start", &json!({})).unwrap();
|
||||
let start_time = before.elapsed();
|
||||
let id: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
|
||||
let before = Instant::now();
|
||||
s.execute("render.cancel", &json!({"job":id})).unwrap();
|
||||
let cancel_request_time = before.elapsed();
|
||||
let status = s.render_job_handle(id).unwrap().wait_timeout(Duration::from_secs(60)).unwrap();
|
||||
let cancellation_time = before.elapsed();
|
||||
assert!(matches!(status.progress.phase, solidcraft_render::job::Phase::Cancelled | solidcraft_render::job::Phase::Completed));
|
||||
if status.progress.phase == solidcraft_render::job::Phase::Cancelled {
|
||||
assert!(status.frame.is_none());
|
||||
}
|
||||
eprintln!(
|
||||
"cached one-box 1024x768: start={start_time:?}; cancel request={cancel_request_time:?}; cancellation acknowledged={cancellation_time:?}"
|
||||
);
|
||||
let reply = s.execute("render.start", &json!({})).unwrap();
|
||||
let id: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
|
||||
let handle = s.render_job_handle(id).unwrap();
|
||||
let watch = Instant::now();
|
||||
let observed = loop {
|
||||
let phase = handle.status().unwrap().progress.phase;
|
||||
if phase == solidcraft_render::job::Phase::Running || phase.terminal() {
|
||||
break phase;
|
||||
}
|
||||
assert!(watch.elapsed() < Duration::from_secs(60), "native worker did not advance");
|
||||
std::thread::yield_now();
|
||||
};
|
||||
let before = Instant::now();
|
||||
s.execute("render.cancel", &json!({"job":id})).unwrap();
|
||||
let request_time = before.elapsed();
|
||||
let status = handle.wait_timeout(Duration::from_secs(60)).unwrap();
|
||||
eprintln!(
|
||||
"native cancellation observed phase={observed:?}: request={request_time:?}; acknowledged={:?}; terminal={:?}",
|
||||
before.elapsed(),
|
||||
status.progress.phase
|
||||
);
|
||||
assert!(status.progress.phase.terminal());
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use crate::{Result, Session, params::bad, render_jobs::job_error};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::render_settings::RenderSetup;
|
||||
use solidcraft_render::{
|
||||
RenderOperation, RenderRequest,
|
||||
job::{ImageRequest, JobId},
|
||||
};
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct Start {
|
||||
setup: Option<String>,
|
||||
settings: Option<RenderSetup>,
|
||||
transparent_background: bool,
|
||||
replace: bool,
|
||||
}
|
||||
|
||||
fn prepare(s: &Session, params: &Value, command: &str) -> Result<(Start, solidcraft_render::ProductScene, ImageRequest, Value)> {
|
||||
let input: Start = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.setup.is_some() && input.settings.is_some() {
|
||||
return Err(bad(command, "choose either setup or settings"));
|
||||
}
|
||||
let scene = crate::product_scene::extract(s)?;
|
||||
let settings = if let Some(name) = &input.setup {
|
||||
s.doc.render.setups.get(name).cloned().ok_or_else(|| bad(command, "no saved render setup with that name"))?
|
||||
} else if let Some(settings) = &input.settings {
|
||||
settings.clone()
|
||||
} else {
|
||||
let mut settings = RenderSetup::default();
|
||||
if let Some(camera) = s.camera {
|
||||
settings.camera = camera;
|
||||
} else {
|
||||
settings.camera.fit(&scene.bounds);
|
||||
}
|
||||
settings
|
||||
};
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings.exposure_ev != 0.0 || settings.environment_rotation != 0.0 || settings.environment_intensity != 1.0 {
|
||||
return Err(bad(command, "exposure and environment controls are not supported by the R4a CPU image executor"));
|
||||
}
|
||||
let host = s.render_job_host().ok_or_else(|| bad(command, "no image host is installed"))?;
|
||||
if !host.is_available() {
|
||||
return Err(bad(command, "image host is unavailable"));
|
||||
}
|
||||
let resolved = solidcraft_render::resolve_request(
|
||||
&[s.render_backend()],
|
||||
&RenderRequest {
|
||||
backend: Some(settings.backend.clone()),
|
||||
mode: settings.mode,
|
||||
operation: RenderOperation::Image,
|
||||
width: u64::from(settings.width),
|
||||
height: u64::from(settings.height),
|
||||
format: Some(settings.format),
|
||||
features: scene.required_features(),
|
||||
policy: Default::default(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
if resolved.executor.as_str() != "cpu-product" {
|
||||
return Err(bad(command, "resolved executor is not installed as an image job host"));
|
||||
}
|
||||
let request = ImageRequest {
|
||||
camera: settings.camera,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
background: settings.background,
|
||||
transparent_background: input.transparent_background,
|
||||
};
|
||||
let admission = solidcraft_render::job::admit(&scene, &request).map_err(job_error)?;
|
||||
let reply = json!({"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":"sorted_alpha_approximation","refraction":false});
|
||||
Ok((input, scene, request, reply))
|
||||
}
|
||||
|
||||
pub(super) fn preflight(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
prepare(s, params, "render.preflight").map(|(_, _, _, reply)| reply)
|
||||
}
|
||||
|
||||
pub(super) fn start(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let (input, scene, request, _) = prepare(s, params, "render.start")?;
|
||||
let status = s.render_jobs.start(s.revision, scene, request, input.replace).map_err(job_error)?;
|
||||
Ok(json!(status))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Job {
|
||||
job: JobId,
|
||||
}
|
||||
|
||||
pub(super) fn status(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let input: Job = serde_json::from_value(params.clone()).map_err(|e| bad("render.status", e.to_string()))?;
|
||||
Ok(json!(s.render_jobs.status(input.job).map_err(job_error)?))
|
||||
}
|
||||
|
||||
pub(super) fn cancel(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let input: Job = serde_json::from_value(params.clone()).map_err(|e| bad("render.cancel", e.to_string()))?;
|
||||
let status = s.render_jobs.status(input.job).map_err(job_error)?;
|
||||
if status.progress.phase.terminal() {
|
||||
return Ok(json!(status));
|
||||
}
|
||||
Ok(json!(s.render_jobs.handle(input.job).map_err(job_error)?.cancel().map_err(job_error)?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Export {
|
||||
job: JobId,
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
overwrite: bool,
|
||||
}
|
||||
|
||||
pub(super) fn export(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let input: Export = serde_json::from_value(params.clone()).map_err(|e| bad("render.export", e.to_string()))?;
|
||||
let path = std::path::Path::new(&input.path);
|
||||
if input.path.len() > 4096 || input.path.chars().any(char::is_control) || !path.extension().is_some_and(|x| x.eq_ignore_ascii_case("png")) {
|
||||
return Err(bad("render.export", "a bounded PNG destination path is required"));
|
||||
}
|
||||
// Atomic native writers are also used for the app's own files and do not
|
||||
// apply a request's path scope. Check the caller's destination explicitly.
|
||||
solidcraft_io::vfs::check(&input.path).map_err(|e| bad("render.export", e.to_string()))?;
|
||||
let frame = s.render_jobs.frame(input.job).map_err(job_error)?;
|
||||
// Replace atomically without reading an arbitrarily large previous target into memory.
|
||||
// Design saves retain their independent backup policy.
|
||||
let result = if input.overwrite {
|
||||
solidcraft_io::write_atomic(path, frame.png(), false)
|
||||
} else {
|
||||
solidcraft_io::safe_write::create_atomic(path, frame.png())
|
||||
};
|
||||
result.map_err(|e| bad("render.export", format!("could not export PNG: {e}")))?;
|
||||
Ok(json!({"job":input.job,"path":input.path,"bytes":frame.png().len(),"frame":frame.info}))
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use super::*;
|
||||
|
||||
fn save(s: &mut Session, patch: Value) {
|
||||
s.execute("render.settings.set", &json!({"name":"Studio", "set":patch})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_history_preserves_geometry_cache_and_dirty_state() {
|
||||
let mut s = Session::default();
|
||||
let component = s.execute("component.create", &json!({"name":"Part"})).unwrap();
|
||||
s.execute("solid.box", &json!({"length":10,"width":20,"height":5})).unwrap();
|
||||
s.execute("component.activate", &json!({"component":0})).unwrap();
|
||||
s.execute("occurrence.move", &json!({"occurrence":component["occurrence"],"translate":[30,0,0]})).unwrap();
|
||||
s.mark_saved();
|
||||
let model = s.model.state();
|
||||
let world = s.world_state();
|
||||
assert!(!Arc::ptr_eq(&model, &world), "exercise the prepared occurrence cache");
|
||||
let mesh = model.bodies[0].mesh();
|
||||
let undo = s.undo.len();
|
||||
s.evaluations = 0;
|
||||
save(&mut s, json!({"exposure_ev":2,"backend":"future-backend"}));
|
||||
assert!(s.is_dirty());
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let doc = s.doc.clone();
|
||||
let revision = s.revision;
|
||||
save(&mut s, json!({"exposure_ev":2}));
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(s.revision, revision);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let restored = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(restored.render, s.doc.render);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.is_dirty());
|
||||
s.execute("render.settings.get", &json!({})).unwrap();
|
||||
assert_eq!(s.redo.len(), 1);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let checkpoint = s.checkpoint();
|
||||
save(&mut s, json!({"width":800}));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let revision = s.revision;
|
||||
save(&mut s, json!({"exposure_ev":2}));
|
||||
assert_eq!(s.redo.len(), 1, "a no-op edit preserves redo");
|
||||
assert_eq!(s.revision, revision);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
s.restore_checkpoint(checkpoint).unwrap();
|
||||
assert_eq!(s.doc.render.setups["Studio"].width, 1024);
|
||||
assert!(Arc::ptr_eq(&model, &s.model.state()));
|
||||
assert!(Arc::ptr_eq(&mesh, &s.model.state().bodies[0].mesh()));
|
||||
assert!(Arc::ptr_eq(&world, &s.world_state()));
|
||||
assert_eq!(s.evaluations, 0);
|
||||
// A geometry change and its history still require normal evaluation.
|
||||
s.execute("solid.box", &json!({"length":2,"width":3,"height":4})).unwrap();
|
||||
assert!(s.evaluations > 0);
|
||||
s.evaluations = 0;
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.evaluations > 0);
|
||||
s.evaluations = 0;
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(s.evaluations > 0);
|
||||
let checkpoint = s.checkpoint();
|
||||
let expected = s.doc.clone();
|
||||
s.execute("solid.box", &json!({"length":1,"width":1,"height":1})).unwrap();
|
||||
s.evaluations = 0;
|
||||
s.execute("edit.restore_checkpoint", &json!({"checkpoint":checkpoint})).unwrap();
|
||||
assert_eq!(*s.doc, *expected);
|
||||
assert!(s.evaluations > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_settings_and_limit_are_atomic() {
|
||||
let mut s = Session::default();
|
||||
let mut camera_with_typo = serde_json::to_value(solidcraft_render::Camera::default()).unwrap();
|
||||
camera_with_typo["fov_degrees"] = json!(45);
|
||||
for patch in [
|
||||
json!({"width":0}),
|
||||
json!({"width":4097}),
|
||||
json!({"height":null}),
|
||||
json!({"unknown":true}),
|
||||
json!({"camera":{"distance":0}}),
|
||||
json!({"camera":camera_with_typo}),
|
||||
json!({"exposure_ev":33}),
|
||||
json!({"environment_intensity":101}),
|
||||
] {
|
||||
let doc = s.doc.clone();
|
||||
let state = (s.revision, s.undo.len(), s.redo.len(), s.evaluations);
|
||||
assert!(s.execute("render.settings.set", &json!({"name":"Studio","set":patch})).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(state, (s.revision, s.undo.len(), s.redo.len(), s.evaluations));
|
||||
}
|
||||
for i in 0..32 {
|
||||
s.execute("render.settings.set", &json!({"name":i.to_string(),"set":{}})).unwrap();
|
||||
}
|
||||
let doc = s.doc.clone();
|
||||
assert!(s.execute("render.settings.set", &json!({"name":"overflow","set":{}})).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert!(s.execute("render.settings.get", &json!({"name":"missing"})).is_err());
|
||||
assert_eq!(s.evaluations, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_history_and_checkpoint_preserve_a_stale_live_drag() {
|
||||
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());
|
||||
let model = s.model.state();
|
||||
s.evaluations = 0;
|
||||
let checkpoint = s.execute("edit.checkpoint", &json!({})).unwrap()["checkpoint"].clone();
|
||||
save(&mut s, json!({"exposure_ev":1}));
|
||||
s.execute("render.settings.get", &json!({"name":"Studio"})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
s.execute("edit.restore_checkpoint", &json!({"checkpoint":checkpoint})).unwrap();
|
||||
assert!(s.doc.render.is_empty());
|
||||
assert!(s.model.is_stale());
|
||||
assert!(Arc::ptr_eq(&model, &s.model.state()));
|
||||
assert_eq!(s.evaluations, 0);
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
assert!(!s.model.is_stale());
|
||||
assert!(s.evaluations > 0);
|
||||
}
|
||||
Loaded 100 of 294 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user