mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 03:25:58 +00:00
Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41779cf7bb | |||
| c36a6f9f90 | |||
| 6eaf7458ab | |||
| ad30a2350e | |||
| 4b3b48388c | |||
| 0d99d33c38 | |||
| bbfad166b0 | |||
| e95e32d72a | |||
| 6fa9e0d2dd | |||
| 8a6332be83 | |||
| 4674f0a61c | |||
| 6fbb1c5d91 | |||
| d05fe3d4de | |||
| 56622e4be8 | |||
| 4dc638109b | |||
| d7a4a29ce3 | |||
| 9fbbf8dc3a | |||
| 5fa8e7d60a | |||
| 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 | |||
| b01b9689c8 | |||
| 4426da6f5d | |||
| 95ea05e7c8 | |||
| 241dac1542 | |||
| 5ead82e627 | |||
| cccbe1255d | |||
| 4e74fea3e5 | |||
| 229de1484c | |||
| e3459edea8 | |||
| e519fbd14d | |||
| 575c037f39 | |||
| a6fbc63746 | |||
| fd12325d90 | |||
| 39bc87a8cc | |||
| 9b0f028f13 | |||
| 764fcaa5a8 | |||
| 9a4e8fbd19 | |||
| 4cfa9c5d34 | |||
| e489e56f3d | |||
| c3aae64a63 | |||
| c469adf456 | |||
| 6de8f6db95 | |||
| 37547f8eba | |||
| f425089983 | |||
| 94faf9ce1b | |||
| 2e6951135a | |||
| 2c754c430b | |||
| 8f48544021 | |||
| 1a456d2d1f | |||
| f79dfe41b6 | |||
| 6ebfcd16b6 | |||
| ed943a74e6 | |||
| aa2ace81af | |||
| 3dbdb71e59 | |||
| c26da73f5f | |||
| 805ea45be6 | |||
| 54a8c807bf | |||
| d399effbf7 | |||
| 8ea1fd643f | |||
| 563bcaff5b | |||
| 6df69e9def | |||
| 86ea018d05 | |||
| 110d00d07e | |||
| d4a7b3abf3 | |||
| a8258eb63a | |||
| 1e1722b7e1 | |||
| fbd8ada754 | |||
| d019f2d5bf | |||
| c29b0d2f13 | |||
| 480fd8d517 | |||
| ec64564f21 | |||
| a6928abf48 | |||
| 88432d226a | |||
| 1ac8bf4aa8 | |||
| dde6d48dd0 | |||
| 4dbc0efff9 | |||
| d114c58540 | |||
| b15180bdff | |||
| 37113c0c13 | |||
| f2d7b220f7 | |||
| 14d928b2c8 | |||
| 435ad0fd8c | |||
| 7879875d49 | |||
| ec8e739aad | |||
| 08b6a8fe0f | |||
| cf93869c4d | |||
| 75f1cdd4c9 | |||
| b5efde94c8 | |||
| 0897ee3781 | |||
| 52fb5ac0e6 | |||
| 377e12a059 | |||
| 3256fffe35 | |||
| 0f4cda0510 | |||
| 3341e2d60b | |||
| 35e58d18ed | |||
| 32d3cf6877 | |||
| 27374ca289 | |||
| d506382aca |
@@ -28,3 +28,11 @@ slow-timeout = { period = "60s" }
|
||||
|
||||
[profile.ci.junit]
|
||||
path = "junit.xml"
|
||||
|
||||
# The action matrix sample runs its designs in parallel worker processes, one per CPU. Run with
|
||||
# other tests beside it (the heavy ones on main), a pi-case hole that takes 8 s alone (#952 makes
|
||||
# it a real hole) went past the matrix's 90 s action limit. It runs on its own.
|
||||
[[profile.ci.overrides]]
|
||||
filter = "binary(action_matrix) & test(=action_matrix_sample)"
|
||||
threads-required = "num-cpus"
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ What did you expect, and what happened instead?
|
||||
|
||||
Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data.
|
||||
|
||||
When the problem shows on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
|
||||
|
||||
## Screenshots of the behavior (when observable)
|
||||
|
||||
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
|
||||
|
||||
@@ -5,91 +5,9 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
|
||||
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
|
||||
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
|
||||
**Why 0.9:** this is a minor bump because the design file format moved to version 17, and
|
||||
because it adds many new features.
|
||||
|
||||
**What's new**
|
||||
- **Face actions work on about 90% of flat faces.** Sketches, offset planes and press-pull now
|
||||
attach to sheet metal rims and holes, plastic features (Boss, Lip, Rest, Snap Fit), lofts,
|
||||
revolved faces, pieces Offset Face moved and walls a Join merged
|
||||
([#677](https://github.com/bherbruck/solidcraft/pull/677), fixing
|
||||
[#681](https://github.com/bherbruck/solidcraft/issues/681)). Across the test models: sketch
|
||||
75% → 90%, offset plane 75% → 90%, press-pull 64% → 85%.
|
||||
- **Joins:** a logo-style part with pieces extruded up and down from one sketch now joins
|
||||
([#679](https://github.com/bherbruck/solidcraft/issues/679)), faces that only partly overlap
|
||||
fuse correctly ([#704](https://github.com/bherbruck/solidcraft/pull/704), fixing
|
||||
[#674](https://github.com/bherbruck/solidcraft/issues/674)), and booleans build the same body
|
||||
every time ([#748](https://github.com/bherbruck/solidcraft/pull/748)), so a design rebuilds
|
||||
identically on every run.
|
||||
- **Sketch:** type a dimension right after picking (D, click, type;
|
||||
[#695](https://github.com/bherbruck/solidcraft/pull/695)); redundant but consistent
|
||||
constraints are accepted, such as a rectangle clover
|
||||
([#699](https://github.com/bherbruck/solidcraft/pull/699)); tool windows for Scale, Offset,
|
||||
Mirror, Move/Copy, Fillet and Chamfer and the rectangular pattern (thanks @wdunn001), shown
|
||||
next to the Sketch Palette, which now lists the running tool's options; a typed Rho for the
|
||||
conic; a Construction Geometries row in the Sketch Palette that hides construction geometry
|
||||
([#804](https://github.com/bherbruck/solidcraft/pull/804)); sketch tool shortcuts open a sketch on the selected plane; Edit Sketch from the viewport
|
||||
menu.
|
||||
- **Features:** feature history on the left side with folders (opt in under Preferences;
|
||||
[#449](https://github.com/bherbruck/solidcraft/pull/449)); Convert to Sheet Metal from a solid
|
||||
or shelled body by its bend edges ([#725](https://github.com/bherbruck/solidcraft/pull/725));
|
||||
a tear corner relief ([#809](https://github.com/bherbruck/solidcraft/pull/809)); tapered and
|
||||
variable fillet radii ([#675](https://github.com/bherbruck/solidcraft/pull/675),
|
||||
[#749](https://github.com/bherbruck/solidcraft/pull/749)) with a radius spline you drag on the
|
||||
edge ([#752](https://github.com/bherbruck/solidcraft/pull/752)); free-form cut-outs stay exact
|
||||
in sheet-metal flat patterns ([#692](https://github.com/bherbruck/solidcraft/pull/692)) and edge
|
||||
flanges run along what cut-outs leave of an edge
|
||||
([#702](https://github.com/bherbruck/solidcraft/pull/702)); and Surface Extrude, sheets from
|
||||
open curves and profile walls ([#815](https://github.com/bherbruck/solidcraft/pull/815)).
|
||||
- **View:** Home and Fit frame only what is shown, and a middle double-click goes Home
|
||||
([#701](https://github.com/bherbruck/solidcraft/pull/701)); a cube navigator option
|
||||
(thanks @Karl-VonRichter, [#664](https://github.com/bherbruck/solidcraft/pull/664)); the window
|
||||
stays responsive and draggable while a design opens, with a Cancel button that stays put
|
||||
([#643](https://github.com/bherbruck/solidcraft/pull/643),
|
||||
[#686](https://github.com/bherbruck/solidcraft/pull/686)).
|
||||
- **Quality:** cuts no longer split walls they don't touch
|
||||
([#670](https://github.com/bherbruck/solidcraft/pull/670)); booleans are faster and use less
|
||||
memory ([#714](https://github.com/bherbruck/solidcraft/pull/714)); STL export welds shared
|
||||
vertices ([#816](https://github.com/bherbruck/solidcraft/pull/816)); complete Japanese
|
||||
translation ([#753](https://github.com/bherbruck/solidcraft/pull/753)).
|
||||
- **Fixes:** pasted features act on the pasted bodies (#613); a refused OK's error clears when
|
||||
you change the inputs (#641); dialogs show the units of their sizes (#630); the wheel over a
|
||||
dialog's list scrolls only the list (#678); a Single hole picked at a circle's centre lands on
|
||||
the centre (#657); axis or tangent plane on a drill tip works (#567); sketch text edits from
|
||||
letter interiors and moves as a whole; a projected edge stays the edge you picked; a size from
|
||||
a bare diameter keeps its unit (#716); Convert Mesh says how big a mesh is and what converts;
|
||||
the Save changes prompt takes arrow keys and Enter.
|
||||
- **MCP:** agent skills for modeling and editing over MCP
|
||||
([#728](https://github.com/bherbruck/solidcraft/pull/728)).
|
||||
- A new README demo and the SELENE rover showcase.
|
||||
|
||||
**File format 17.** Designs are now format 17 (`solidcraft/17`). SolidCraft 0.8 and older can't
|
||||
open designs saved by 0.9, as a container or as JSON; they ask you to update instead. Keep a
|
||||
copy of a file you may still open in 0.8. Files from every earlier version still open in 0.9.
|
||||
|
||||
**Upgrading from 0.8.** Nothing to do: open your designs and save (they're saved as format 17
|
||||
from then on). Feature-history folders on
|
||||
the left are off by default; turn them on in Preferences.
|
||||
|
||||
**Known limitations**
|
||||
- Some through-all holes along a wall or beside curved faces still fail
|
||||
([#559](https://github.com/bherbruck/solidcraft/issues/559)), as does a hole crossing an
|
||||
equal-radius hole ([#788](https://github.com/bherbruck/solidcraft/issues/788)).
|
||||
- Some fillets are still refused, such as along an outline of lines and arcs
|
||||
([#431](https://github.com/bherbruck/solidcraft/issues/431)).
|
||||
- Some faces still can't take an attached sketch: a revolve's flat faces before a later feature
|
||||
([#726](https://github.com/bherbruck/solidcraft/issues/726)) and faces after Split Body or
|
||||
Combine Intersect ([#565](https://github.com/bherbruck/solidcraft/issues/565)).
|
||||
- Fillet and chamfer accept an edge pick far from any edge and change the nearest one
|
||||
([#783](https://github.com/bherbruck/solidcraft/issues/783)); Press Pull fails on faces beside
|
||||
rounds ([#562](https://github.com/bherbruck/solidcraft/issues/562)).
|
||||
- `sheet.fold` refuses a fixed point exactly on the sheet face
|
||||
([#685](https://github.com/bherbruck/solidcraft/issues/685)), and some tabs on L-shaped or
|
||||
collinear blanks can't be folded ([#638](https://github.com/bherbruck/solidcraft/issues/638),
|
||||
[#639](https://github.com/bherbruck/solidcraft/issues/639)); an edge flange with different
|
||||
per-edge angles leaves gaps at the corners
|
||||
([#501](https://github.com/bherbruck/solidcraft/issues/501)).
|
||||
- More in [ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
**What's new** in SolidCraft {version} is listed below, and the
|
||||
[ROADMAP](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations) lists
|
||||
the known limitations. Designs from every earlier version open in this one.
|
||||
|
||||
**Upgrading from 0.7 or earlier.** 0.8 changed the Linux app ID to `ai.storyteller.solidcraft`:
|
||||
`flatpak update` offers to replace the old app, and the `.deb`, `.rpm` and AppImage remove the old
|
||||
|
||||
@@ -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
|
||||
@@ -286,9 +323,58 @@ jobs:
|
||||
name: oracle-report
|
||||
path: docs/oracle.md
|
||||
|
||||
# D3D12's older shader compiler (FXC, used where dxcompiler.dll is absent) is stricter than
|
||||
# DXC, Vulkan and Metal: a shader it cannot compile once crashed the app at startup. Build the
|
||||
# GPU tessellation pipeline with FXC on the runner's WARP software adapter and compare its
|
||||
# results with the CPU.
|
||||
windows-fxc:
|
||||
name: Windows D3D12 FXC shaders
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
WGPU_BACKEND: dx12
|
||||
WGPU_DX12_COMPILER: fxc
|
||||
SOLIDCRAFT_REQUIRE_GPU: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-fxc
|
||||
# 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]
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -302,6 +388,8 @@ jobs:
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
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
|
||||
@@ -311,6 +399,8 @@ jobs:
|
||||
test "$TESTS" = success
|
||||
test "$DOCS" = success
|
||||
test "$ORACLE" = success
|
||||
test "$WINDOWS_FXC" = success
|
||||
test "$WINDOWS_STARTUP" = success
|
||||
fi
|
||||
|
||||
wasm:
|
||||
|
||||
@@ -429,6 +429,32 @@ jobs:
|
||||
run: |
|
||||
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
|
||||
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
|
||||
- name: Sign the checksum list for in-app updates
|
||||
# Optional until the UPDATE_SIGNING_KEY secret exists (docs/releasing.md, "Update
|
||||
# signing"). The trusted comment names the release, which the app checks.
|
||||
env:
|
||||
UPDATE_SIGNING_KEY: ${{ secrets.UPDATE_SIGNING_KEY }}
|
||||
run: |
|
||||
# The key the app checks against (crates/ui-egui/src/update/install.rs, PUBLIC_KEY).
|
||||
public_key=$(sed -n 's/^pub const PUBLIC_KEY: Option<&str> = Some("\([^"]*\)");/\1/p' crates/ui-egui/src/update/install.rs)
|
||||
if [ -z "$UPDATE_SIGNING_KEY" ]; then
|
||||
if [ -n "$public_key" ]; then
|
||||
echo "::error::the app expects signed updates (PUBLIC_KEY is set) but the UPDATE_SIGNING_KEY secret is missing"
|
||||
exit 1
|
||||
fi
|
||||
echo "UPDATE_SIGNING_KEY isn't set: SHA256SUMS.txt stays unsigned"
|
||||
exit 0
|
||||
fi
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y --no-install-recommends minisign
|
||||
umask 077
|
||||
printf '%s\n' "$UPDATE_SIGNING_KEY" >"$RUNNER_TEMP/update.key"
|
||||
minisign -S -s "$RUNNER_TEMP/update.key" -m artifacts/SHA256SUMS.txt \
|
||||
-x artifacts/SHA256SUMS.txt.minisig -t "SolidCraft v$VERSION" </dev/null
|
||||
rm -f "$RUNNER_TEMP/update.key"
|
||||
if [ -n "$public_key" ]; then
|
||||
minisign -V -P "$public_key" -m artifacts/SHA256SUMS.txt -x artifacts/SHA256SUMS.txt.minisig
|
||||
fi
|
||||
- name: Publish nightly pre-release
|
||||
if: github.event_name == 'schedule'
|
||||
working-directory: artifacts
|
||||
|
||||
@@ -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,7 +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"`).
|
||||
`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
|
||||
|
||||
+8
-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,8 +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 |
|
||||
@@ -119,6 +125,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| crates/mcp/src/tools.rs (tool schema helpers and argument checking) | CADCraft `crates/mcp/src/tools.rs`, GridCraft `crates/mcp/src/tools.rs` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solidcraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
|
||||
| apps/solidcraft/src/open_documents.rs (macOS: files opened from Finder and the Dock, its test) | VectorCraft `apps/vectorcraft/src/open_documents.rs` (https://github.com/storytold/vectorcraft) | MIT OR Apache-2.0 |
|
||||
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
|
||||
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
|
||||
|
||||
@@ -144,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,7 +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"`).
|
||||
`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
-1
@@ -107,7 +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`, and clippy 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
+268
-17
@@ -422,6 +422,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
@@ -710,6 +716,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -723,7 +739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -736,7 +752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1578,6 +1594,22 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
@@ -1987,6 +2019,12 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2449,6 +2487,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -2949,6 +2993,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "robust"
|
||||
version = "1.2.0"
|
||||
@@ -3016,6 +3074,80 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -3031,6 +3163,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -3043,6 +3184,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
@@ -3281,41 +3445,61 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"getrandom 0.3.4",
|
||||
"hidapi",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster 0.4.0",
|
||||
"quick-xml",
|
||||
"rfd",
|
||||
"ron",
|
||||
"rustls",
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-ui-egui",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-mcp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solidcraft-derive",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"solidcraft-render",
|
||||
"solidcraft-sketch",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
@@ -3323,7 +3507,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3339,7 +3523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3348,9 +3532,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
"pollster 0.4.0",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
@@ -3360,7 +3545,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"quick-xml",
|
||||
"serde",
|
||||
@@ -3375,7 +3560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -3393,7 +3578,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3401,9 +3586,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-geom",
|
||||
@@ -3411,7 +3597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3422,24 +3608,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"pollster 0.4.0",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"ttf-parser",
|
||||
"web-time",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3486,6 +3676,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -3908,6 +4104,40 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -3920,6 +4150,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -4242,6 +4478,15 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -4730,7 +4975,7 @@ dependencies = [
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics",
|
||||
"cursor-icon",
|
||||
"dpi",
|
||||
@@ -4862,7 +5107,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -5038,6 +5283,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
|
||||
+9
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -24,13 +24,19 @@ 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"
|
||||
semver = "1"
|
||||
|
||||
# geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it.
|
||||
truck-modeling = "0.6"
|
||||
@@ -52,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"
|
||||
@@ -61,6 +68,7 @@ web-time = "1"
|
||||
egui-wgpu = "0.36"
|
||||
# the wgpu egui-wgpu uses, for compute (crates/gpu); backends come from the apps' features
|
||||
wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] }
|
||||
naga = { version = "30", default-features = false }
|
||||
pollster = "0.4"
|
||||
rfd = "0.17"
|
||||
|
||||
|
||||
@@ -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,26 @@ 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
|
||||
along straight directions. Box-bounded and four-cornered faces mesh as structured grids.
|
||||
Trimmed faces are cut into four-sided patches: squares around holes, butterflies in round
|
||||
faces, motorcycle traces from concave corners (after Lyon et al. 2019). Each patch then meshes
|
||||
as a Coons grid. Intersection edges are evaluated by Newton on both surfaces. A compute shader
|
||||
evaluates the plans for the whole scene in one dispatch, with a CPU fallback when the GPU
|
||||
can't build it. Planning and evaluating the 78 reference bodies takes about 65 ms, 50–65×
|
||||
faster than the previous mesher.
|
||||
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
|
||||
axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
|
||||
control channel so agents and tests can drive it.
|
||||
@@ -151,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
|
||||
@@ -215,7 +232,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
|
||||
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
|
||||
| L4 | `engine` | the session and the command registry (everything is a command) |
|
||||
| L5 | `ui-egui`, `mcp` | the swappable desktop front end; the MCP server (headless or bridged to the app) |
|
||||
| L5 | `gpu`, `ui-egui`, `mcp` | compute-shader evaluation of display tessellation plans; the swappable desktop front end; the MCP server (headless or bridged to the app) |
|
||||
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
|
||||
<!-- ANCHOR_END: architecture -->
|
||||
|
||||
|
||||
+90
-8
@@ -1,6 +1,6 @@
|
||||
# SolidCraft roadmap
|
||||
|
||||
Status as of **2026-10-11**. SolidCraft supports parametric modelling (sketch →
|
||||
Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch →
|
||||
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
|
||||
|
||||
## At a glance
|
||||
@@ -9,7 +9,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
|---|---|
|
||||
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
|
||||
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
|
||||
| Reference tests | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR. |
|
||||
| Reference tests | **82 / 82 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (61) or FreeCAD-computed (21) expected values, run by CI on every PR. |
|
||||
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||
@@ -21,6 +21,8 @@ offsets and plastic face placements, XZ (+Y normal) with a v6 migration preservi
|
||||
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
|
||||
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
|
||||
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||
Face-based Extrude Join (including outward Press Pull) now defaults to the picked body,
|
||||
so edge-touching assembly neighbours stay separate regardless of body order (#226).
|
||||
|
||||
In scope, in priority order:
|
||||
1. SOLID and SKETCH (now).
|
||||
@@ -50,15 +52,20 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
|
||||
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
|
||||
@@ -67,8 +74,18 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
persist properties, provide a solver or enable rendering; see
|
||||
[the contract and staged follow-ups](docs/physical-material-cards.md).
|
||||
|
||||
- Directional elasticity (#994) queries the validated card in material-local axes
|
||||
through read-only `material.properties.directional`, with source/condition review.
|
||||
It does not assign body frames, predict structural behavior or enable a solver.
|
||||
|
||||
## Library foundations (#785, #786, #787)
|
||||
|
||||
- Local scalar libraries (#846) inspect/export bounded portable catalogs and copy exact
|
||||
selected appearance/material revisions into existing design definitions in one undo step.
|
||||
Appearance offers local loading/search/export; physical materials use existing commands.
|
||||
This slice is independent of the render stack and does not change the document format.
|
||||
See [the contract and limits](docs/book/src/dev/local-libraries.md).
|
||||
|
||||
- Proposed phases cover offline bundled/project/user packs, physical materials and
|
||||
appearances, editable wood mapping, and parameterized component/assembly families.
|
||||
Stable identity/revision, copied design definitions, provenance and resource limits
|
||||
@@ -79,14 +96,51 @@ 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.
|
||||
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;
|
||||
it does not yet enable HDRI/PBR rendering or a document import command. See
|
||||
[the input contract](docs/render-hdr-input.md) and #813 for integration work.
|
||||
- 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
|
||||
@@ -155,6 +209,13 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
Crash Reports…) with a reviewed issue draft; agents read them through
|
||||
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
|
||||
anywhere. See [crash reports](docs/crash-reports.md).
|
||||
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
|
||||
writes a design's history as a script that `eval` and `run` replay into the same design —
|
||||
user parameters, units, sheet metal and plastic rules and materials as their own commands,
|
||||
then each feature through `timeline.add` with its ids, names and dimension parameters kept,
|
||||
so every feature kind replays exactly and a failing feature fails the same way. Trimming the
|
||||
script after a step gives the design at that step. Appearances, folders, views and
|
||||
configurations are listed as left out; components and joints are not supported yet.
|
||||
|
||||
## 0.2.0 release batch
|
||||
|
||||
@@ -242,7 +303,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;
|
||||
@@ -276,6 +338,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
|
||||
touch (no clean crossing) enter that profile as polylines.
|
||||
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
|
||||
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
|
||||
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
|
||||
walls through denser stations where the path has corners or the profile has holes.
|
||||
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
|
||||
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
|
||||
certificate and refine within bounded node limits while retaining existing IDs.
|
||||
@@ -421,6 +486,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
|
||||
@@ -466,6 +540,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.
|
||||
|
||||
+1294
-36
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
//! `bench-agent`: a large design built the way an agent builds one over MCP, one small command
|
||||
//! at a time, timed as it grows and then opened again.
|
||||
//!
|
||||
//! The design is a bicycle of about 1,300 bodies: frame tubes joined at root and then moved into
|
||||
//! a component, two wheels whose spokes and nipples are placed one by one, a chainring with
|
||||
//! patterned teeth, a cassette, a chain of individual links, cockpit, seat and a few hundred
|
||||
//! fasteners. Nothing is read from a file; the script is generated here, so the benchmark needs
|
||||
//! no fixture.
|
||||
//!
|
||||
//! ```text
|
||||
//! solidcraft-cli bench-agent [--scale F] [--script-out script.json] [--save design.solidcraft]
|
||||
//! ```
|
||||
//! prints one JSON line per hundred commands (mean and max milliseconds, bodies so far), then the
|
||||
//! build total, then the phases `bench-open` prints for the saved design.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
|
||||
struct Script {
|
||||
commands: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Script {
|
||||
fn c(&mut self, command: &str, params: Value) {
|
||||
self.commands.push(json!({"command": command, "params": params}));
|
||||
}
|
||||
|
||||
/// A round tube from `a` to `b` (an agent's frame tube, spoke or post).
|
||||
fn tube(&mut self, a: [f64; 3], b: [f64; 3], radius: f64, body: &str) {
|
||||
let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
let axis = [d[0] / len, d[1] / len, d[2] / len];
|
||||
self.c(
|
||||
"solid.cylinder",
|
||||
json!({"radius": radius, "height": round3(len), "base": a.map(round3), "axis": axis.map(round6), "body_name": body}),
|
||||
);
|
||||
}
|
||||
|
||||
fn component(&mut self, name: &str) {
|
||||
self.c("component.create", json!({"name": name}));
|
||||
}
|
||||
|
||||
fn root(&mut self) {
|
||||
self.c("component.activate", json!({"component": "root"}));
|
||||
}
|
||||
|
||||
/// A wheel built at the origin in its own component, then placed where it goes (the
|
||||
/// component's occurrence moved), as an agent builds one part and positions it.
|
||||
fn wheel(&mut self, label: &str, at: f64, spokes: usize) {
|
||||
let cx = 0.0;
|
||||
self.component(&format!("{label} wheel"));
|
||||
self.c("solid.torus", json!({"major": 311, "minor": 12, "center": [cx, 0, 340], "body_name": format!("{label} rim")}));
|
||||
self.c("solid.torus", json!({"major": 330, "minor": 18, "center": [cx, 0, 340], "body_name": format!("{label} tyre")}));
|
||||
self.c("solid.cylinder", json!({"radius": 18, "height": 110, "base": [cx, -55, 340], "axis": "Y", "body_name": format!("{label} hub")}));
|
||||
for k in 0..spokes {
|
||||
let a = std::f64::consts::TAU * k as f64 / spokes as f64;
|
||||
let side = if k % 2 == 1 { 1.0 } else { -1.0 };
|
||||
let hub = [cx + 30.0 * a.cos(), side * 40.0, 340.0 + 30.0 * a.sin()];
|
||||
let (ca, sa) = ((a + 0.12).cos(), (a + 0.12).sin());
|
||||
let rim = [cx + 300.0 * ca, side * 4.0, 340.0 + 300.0 * sa];
|
||||
self.tube(hub, rim, 1.0, &format!("{label} spoke {k}"));
|
||||
let nipple = json!({"radius": 2, "height": 12, "base": rim.map(round3), "axis": [round6(ca), 0, round6(sa)], "body_name": format!("{label} nipple {k}")});
|
||||
self.c("solid.cylinder", nipple);
|
||||
}
|
||||
self.root();
|
||||
self.c("occurrence.move", json!({"occurrence": format!("{label} wheel:1"), "translate": [at, 0, 0]}));
|
||||
}
|
||||
}
|
||||
|
||||
fn round3(v: f64) -> f64 {
|
||||
(v * 1e3).round() / 1e3
|
||||
}
|
||||
|
||||
fn round6(v: f64) -> f64 {
|
||||
(v * 1e6).round() / 1e6
|
||||
}
|
||||
|
||||
/// The bicycle's command script. `scale` multiplies the counts of repeated parts (spokes,
|
||||
/// chain links, fasteners); 1.0 gives about 1,300 bodies.
|
||||
pub fn bicycle(scale: f64) -> Value {
|
||||
let n = |k: f64| ((k * scale).round() as usize).max(1);
|
||||
let mut s = Script { commands: Vec::new() };
|
||||
// Frame: tubes at root, the main triangle joined, then everything moved into its component.
|
||||
let (bb, head_top, head_bot, seat_top) = ([0.0, 0.0, 300.0], [600.0, 0.0, 860.0], [580.0, 0.0, 740.0], [-150.0, 0.0, 820.0]);
|
||||
s.tube(bb, head_bot, 19.0, "Down tube");
|
||||
s.tube(seat_top, head_top, 16.0, "Top tube");
|
||||
s.tube(bb, seat_top, 16.0, "Seat tube");
|
||||
s.tube(head_bot, head_top, 22.0, "Head tube");
|
||||
for (side, y) in [("L", 60.0), ("R", -60.0)] {
|
||||
s.tube([0.0, y * 0.3, 300.0], [-420.0, y, 340.0], 10.0, &format!("Chainstay {side}"));
|
||||
s.tube([-140.0, y * 0.3, 800.0], [-420.0, y, 340.0], 9.0, &format!("Seatstay {side}"));
|
||||
s.c("solid.box", json!({"length": 20, "width": 6, "height": 40, "corner": [-430.0, y - 3.0, 320], "body_name": format!("Dropout {side}")}));
|
||||
}
|
||||
s.c("solid.cylinder", json!({"radius": 24, "height": 80, "base": [0, -40, 300], "axis": "Y", "body_name": "BB shell"}));
|
||||
s.c("solid.combine", json!({"target": "Down tube", "tools": ["Top tube", "Seat tube", "Head tube", "BB shell"]}));
|
||||
s.component("Frame");
|
||||
s.root();
|
||||
let frame = ["Down tube", "Chainstay L", "Seatstay L", "Dropout L", "Chainstay R", "Seatstay R", "Dropout R"];
|
||||
s.c("component.move_bodies", json!({"bodies": frame, "component": "Frame"}));
|
||||
// Wheels: every spoke and nipple a body of its own.
|
||||
s.wheel("Front", 640.0, n(36.0));
|
||||
s.wheel("Rear", -420.0, n(36.0));
|
||||
// Drivetrain: patterned chainring teeth, a cassette and a chain of separate links.
|
||||
s.component("Drivetrain");
|
||||
s.c("solid.cylinder", json!({"radius": 95, "height": 4, "base": [0, 50, 300], "axis": "Y", "body_name": "Chainring"}));
|
||||
s.c("solid.box", json!({"length": 6, "width": 4, "height": 8, "corner": [-3, 50, 395], "name": "Tooth", "body_name": "Tooth"}));
|
||||
s.c("solid.pattern.circular", json!({"bodies": ["Tooth"], "axis": {"origin": [0, 50, 300], "dir": [0, 1, 0]}, "count": 44}));
|
||||
for g in 0..11 {
|
||||
let r = 22.0 + 3.5 * g as f64;
|
||||
s.c(
|
||||
"solid.cylinder",
|
||||
json!({"radius": r, "height": 2, "base": [-420.0, round3(30.0 + 2.6 * g as f64), 340.0], "axis": "Y", "body_name": format!("Cog {g}")}),
|
||||
);
|
||||
}
|
||||
let links = n(112.0);
|
||||
for k in 0..links {
|
||||
let a = std::f64::consts::TAU * k as f64 / links as f64;
|
||||
let (x, z) = (round3(-210.0 + 230.0 * a.cos()), round3(320.0 + 80.0 * a.sin()));
|
||||
for (side, y) in [("in", 46), ("out", 56)] {
|
||||
s.c("solid.box", json!({"length": 12, "width": 1, "height": 8, "corner": [round3(x - 6.0), y, round3(z - 4.0)], "body_name": format!("Link {k} {side}")}));
|
||||
}
|
||||
s.c("solid.cylinder", json!({"radius": 3.5, "height": 10, "base": [x, 46, z], "axis": "Y", "body_name": format!("Roller {k}")}));
|
||||
}
|
||||
for (side, y) in [("L", -90.0), ("R", 90.0)] {
|
||||
let x0 = if side == "L" { -15.0 } else { 0.0 };
|
||||
s.c("solid.box", json!({"length": 170, "width": 12, "height": 30, "corner": [x0, y - 6.0, 285], "body_name": format!("Crank {side}")}));
|
||||
let py = if y > 0.0 { y + 8.0 } else { y - 68.0 };
|
||||
s.c("solid.box", json!({"length": 90, "width": 60, "height": 15, "corner": [150.0, py, 280], "body_name": format!("Pedal {side}")}));
|
||||
}
|
||||
s.root();
|
||||
// Cockpit and seat.
|
||||
s.component("Cockpit");
|
||||
s.tube([600.0, 0.0, 860.0], [620.0, 0.0, 940.0], 14.0, "Steerer");
|
||||
s.tube([620.0, 0.0, 940.0], [700.0, 0.0, 950.0], 16.0, "Stem");
|
||||
s.tube([700.0, -380.0, 950.0], [700.0, 380.0, 950.0], 11.0, "Handlebar");
|
||||
s.c("solid.cylinder", json!({"radius": 16, "height": 120, "base": [700, 260, 950], "axis": "Y", "body_name": "Grip R"}));
|
||||
s.c("solid.mirror", json!({"bodies": ["Grip R"], "plane": "XZ"}));
|
||||
s.root();
|
||||
s.component("Seat");
|
||||
s.tube([-150.0, 0.0, 820.0], [-170.0, 0.0, 960.0], 13.0, "Seatpost");
|
||||
s.c("solid.box", json!({"length": 260, "width": 140, "height": 40, "center": [-180, 0, 980], "body_name": "Saddle"}));
|
||||
s.root();
|
||||
// Fasteners: head, shank and washer, each its own body.
|
||||
s.component("Hardware");
|
||||
let bolts = n(240.0);
|
||||
for k in 0..bolts {
|
||||
let a = std::f64::consts::TAU * k as f64 / bolts as f64;
|
||||
let p = [round3(-300.0 + 2.5 * k as f64), round3(120.0 + 10.0 * a.sin()), round3(400.0 + 10.0 * a.cos())];
|
||||
s.c("solid.cylinder", json!({"radius": 5, "height": 4, "base": p, "axis": "Z", "body_name": format!("Bolt {k} head")}));
|
||||
s.c(
|
||||
"solid.cylinder",
|
||||
json!({"radius": 2.5, "height": 16, "base": [p[0], p[1], round3(p[2] - 16.0)], "axis": "Z", "body_name": format!("Bolt {k} shank")}),
|
||||
);
|
||||
s.c(
|
||||
"solid.cylinder",
|
||||
json!({"radius": 4.5, "height": 1, "base": [p[0], p[1], round3(p[2] - 1.0)], "axis": "Z", "body_name": format!("Bolt {k} washer")}),
|
||||
);
|
||||
}
|
||||
s.root();
|
||||
json!({"commands": s.commands})
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1));
|
||||
let scale = match flag("--scale") {
|
||||
Some(v) => v.parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 20.0).ok_or("--scale takes a number from 0 to 20")?,
|
||||
None => 1.0,
|
||||
};
|
||||
let script = bicycle(scale);
|
||||
if let Some(path) = flag("--script-out") {
|
||||
let text = serde_json::to_string_pretty(&script).map_err(|e| e.to_string())?;
|
||||
return solidcraft_engine::io::vfs::write(path, text.as_bytes()).map_err(|e| format!("{path}: {e}"));
|
||||
}
|
||||
let commands = script["commands"].as_array().cloned().unwrap_or_default();
|
||||
let mut s = Session::default();
|
||||
let began = std::time::Instant::now();
|
||||
// Per command: the command itself, then what a window needs to draw its result (the
|
||||
// bodies placed in the world and their display meshes).
|
||||
let (mut run, mut shown): (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
|
||||
for (i, c) in commands.iter().enumerate() {
|
||||
let id = c["command"].as_str().unwrap_or_default();
|
||||
let t = std::time::Instant::now();
|
||||
s.execute(id, &c["params"]).map_err(|e| format!("command {i} ({id}): {e}"))?;
|
||||
run.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
let t = std::time::Instant::now();
|
||||
s.world_state().warm_meshes(|| true);
|
||||
shown.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
if run.len() == 100 || i + 1 == commands.len() {
|
||||
let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len().max(1) as f64;
|
||||
let bodies = s.model.state().bodies.len();
|
||||
println!(
|
||||
"{}",
|
||||
json!({"step": "build", "commands": i + 1, "bodies": bodies, "command_ms": mean(&run), "display_ms": mean(&shown),
|
||||
"max_ms": run.iter().zip(&shown).map(|(a, b)| a + b).fold(0.0, f64::max)})
|
||||
);
|
||||
run.clear();
|
||||
shown.clear();
|
||||
}
|
||||
}
|
||||
let world = s.world_state();
|
||||
println!(
|
||||
"{}",
|
||||
json!({"step": "built", "ms": began.elapsed().as_secs_f64() * 1000.0, "commands": commands.len(), "features": s.doc.features.len(), "bodies": world.bodies.len()})
|
||||
);
|
||||
let path = match flag("--save") {
|
||||
Some(p) => p.clone(),
|
||||
None => std::env::temp_dir().join(format!("bench-agent-{}.solidcraft", std::process::id())).to_string_lossy().into_owned(),
|
||||
};
|
||||
s.execute("file.save", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
let r = crate::cmd_bench_open(std::slice::from_ref(&path));
|
||||
if flag("--save").is_none() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{path}.bak"));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The generated commands stay valid as the command set changes: a small bicycle builds
|
||||
/// without a failing command or feature.
|
||||
#[test]
|
||||
fn a_small_bicycle_builds() {
|
||||
let script = bicycle(0.03);
|
||||
let mut s = Session::default();
|
||||
for c in script["commands"].as_array().into_iter().flatten() {
|
||||
let id = c["command"].as_str().unwrap_or_default();
|
||||
if let Err(e) = s.execute(id, &c["params"]) {
|
||||
panic!("{id}: {e}");
|
||||
}
|
||||
}
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()));
|
||||
let world = s.world_state();
|
||||
assert!(world.bodies.len() > 40, "{} bodies", world.bodies.len());
|
||||
assert!(world.bodies.iter().any(|b| b.name.contains("spoke")));
|
||||
}
|
||||
}
|
||||
+197
-47
@@ -1,10 +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 bench-open <design.solidcraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solidcraft-cli 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] [--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> [--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
|
||||
@@ -17,10 +19,12 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod bench_agent;
|
||||
mod crashes;
|
||||
mod fuzz;
|
||||
mod oracle;
|
||||
mod recipe;
|
||||
mod render;
|
||||
mod spatial;
|
||||
|
||||
use std::process::ExitCode;
|
||||
@@ -30,15 +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 bench-open <design.solidcraft | part.step> [--parse-only]
|
||||
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solidcraft-cli 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] [--display LEVEL [--edit NAME EXPR]]
|
||||
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
|
||||
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>
|
||||
@@ -55,7 +71,9 @@ fn main() -> ExitCode {
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("run") => cmd_run(&args[1..]),
|
||||
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 _;
|
||||
@@ -91,6 +109,8 @@ fn main() -> ExitCode {
|
||||
// Hidden: time recomputes after parameter edits (docs/perf.md).
|
||||
Some("bench-edit") => cmd_bench_edit(&args[1..]),
|
||||
Some("bench-open") => cmd_bench_open(&args[1..]),
|
||||
// A ~1,300-body design built command by command, as an agent builds one over MCP.
|
||||
Some("bench-agent") => bench_agent::run(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
@@ -156,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);
|
||||
@@ -164,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
|
||||
@@ -204,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(())
|
||||
}
|
||||
@@ -226,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())?;
|
||||
@@ -238,21 +314,56 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
|
||||
"params": d["params"],
|
||||
"sketches": d["sketches"],
|
||||
});
|
||||
println!("{}", pretty(&out));
|
||||
print_json(&out, json_out)
|
||||
}
|
||||
|
||||
/// A design's history as a command script (`timeline.export_script`): printed, or written to
|
||||
/// `--out`; what the script leaves out goes to stderr.
|
||||
fn cmd_script(args: &[String]) -> Result<(), String> {
|
||||
let path = positional(args).ok_or_else(|| USAGE.to_string())?;
|
||||
let s = &mut load(path)?;
|
||||
let out = flag(args, "--out");
|
||||
let r = s.execute("timeline.export_script", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
for what in r["omitted"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
eprintln!("note: the script leaves out {what}");
|
||||
}
|
||||
match out {
|
||||
Some(out) => eprintln!("{out}: {} steps", r["steps"]),
|
||||
None => println!("{}", pretty(&r["script"])),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or_else(|| USAGE.to_string())?;
|
||||
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
|
||||
let w = flag(args, "--width").and_then(|x| x.parse().ok()).unwrap_or(1280usize);
|
||||
let h = flag(args, "--height").and_then(|x| x.parse().ok()).unwrap_or(800usize);
|
||||
let s = load(path)?;
|
||||
let mut cam = 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()})));
|
||||
@@ -333,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()),
|
||||
};
|
||||
@@ -401,7 +519,7 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
|
||||
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]")?;
|
||||
let t = std::time::Instant::now();
|
||||
let step = solidcraft_engine::io::is_step_path(path);
|
||||
@@ -439,5 +557,37 @@ 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);
|
||||
@@ -296,6 +296,17 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
Some("sketch") => sketch_cmds(f, &mut out)?,
|
||||
Some("extrude") => {
|
||||
let mut p = json!({"sketch": profile_sketch(f), "profiles": profiles_of(f), "operation": op(f), "name": name, "body_names": bodies});
|
||||
// Thin: {sketch, curves, thickness, side?, thickness2?} instead of profiles.
|
||||
if let (Some(thin), Some(params)) = (f.get("thin"), p.as_object_mut()) {
|
||||
params.remove("profiles");
|
||||
params.insert("thin".into(), json!(true));
|
||||
params.insert("sketch".into(), thin.get("sketch").cloned().unwrap_or(Value::Null));
|
||||
for key in ["curves", "thickness", "side", "thickness2"] {
|
||||
if let Some(v) = thin.get(key) {
|
||||
params.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
extent(f, &mut p);
|
||||
out.push(json!({"command": "solid.extrude", "params": p}));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -693,6 +710,8 @@ fn action_matrix_worker() {
|
||||
&& let Some((name, expr)) = &edit
|
||||
&& stable_hash(&format!("{k} edit")).is_multiple_of(EDIT_SAMPLE)
|
||||
{
|
||||
// Marked like an action, so a hang here is put down to the edit round (not to loading).
|
||||
emit(json!({"start": i, "edit": true}));
|
||||
let t = Instant::now();
|
||||
let start = s.doc.features.len();
|
||||
let r = scratch
|
||||
@@ -706,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();
|
||||
@@ -757,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()
|
||||
@@ -785,13 +914,18 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
|
||||
.map(|f| std::io::BufReader::new(f).lines().map_while(Result::ok).filter_map(|l| serde_json::from_str(&l).ok()).collect())
|
||||
.unwrap_or_default();
|
||||
let done = lines.iter().any(|l| l.get("done").is_some());
|
||||
let last_start = lines.iter().rev().take_while(|l| l.get("start").is_some() || l.get("design").is_none()).find_map(|l| l["start"].as_u64());
|
||||
let last = lines.iter().rev().take_while(|l| l.get("start").is_some() || l.get("design").is_none()).find(|l| l.get("start").is_some());
|
||||
let last_start = last.and_then(|l| l["start"].as_u64());
|
||||
let in_edit = last.is_some_and(|l| l["edit"] == true);
|
||||
results = lines.iter().filter(|l| l.get("design").is_some()).cloned().collect();
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
// 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:?})")}),
|
||||
@@ -800,7 +934,7 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
|
||||
};
|
||||
// Which action was it? The child enumerates them; ask it by running from there with a
|
||||
// marker: simplest is to record the index and let the next run name it.
|
||||
extra.push(json!({"design": design, "index": i, "kind": if timed_out { "timeout" } else { "crash" },
|
||||
extra.push(json!({"design": design, "index": i, "edit": in_edit, "kind": if timed_out { "timeout" } else { "crash" },
|
||||
"message": if timed_out { format!("no result after {} s", ACTION_TIMEOUT.as_secs()) } else { format!("the process ended: {status:?}") }}));
|
||||
from = i as usize + 1;
|
||||
}
|
||||
@@ -809,7 +943,10 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
|
||||
let names = action_names(design);
|
||||
for e in &mut extra {
|
||||
if let Some(i) = e.get("index").and_then(Value::as_u64) {
|
||||
let (entity, action) = names.get(i as usize).cloned().unwrap_or_else(|| (format!("action{i}"), "unknown".into()));
|
||||
let (entity, mut action) = names.get(i as usize).cloned().unwrap_or_else(|| (format!("action{i}"), "unknown".into()));
|
||||
if e["edit"] == true {
|
||||
action += "+edit";
|
||||
}
|
||||
*e = json!({"design": design, "entity": entity, "action": action, "ok": false, "kind": e["kind"], "message": e["message"]});
|
||||
}
|
||||
}
|
||||
@@ -856,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.
|
||||
@@ -887,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) => {
|
||||
@@ -927,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{}",
|
||||
@@ -945,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
|
||||
@@ -1031,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,86 @@
|
||||
//! A design's command script (`timeline.export_script`, #886) replays into the same design:
|
||||
//! every corpus design that the script supports comes back with the same timeline, parameters,
|
||||
//! settings and bodies.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
|
||||
fn corpus() -> Vec<PathBuf> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
|
||||
let mut v: Vec<PathBuf> =
|
||||
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solidcraft")).collect();
|
||||
v.sort();
|
||||
assert!(v.len() >= 7, "corpus files in {}", dir.display());
|
||||
v
|
||||
}
|
||||
|
||||
/// Designs with components or joints, which scripts do not support yet.
|
||||
const ASSEMBLIES: [&str; 2] = ["components-arm", "joints-hinge"];
|
||||
|
||||
fn bodies(s: &mut Session) -> Vec<(String, f64, f64, Value)> {
|
||||
let m = s.execute("inspect.measure", &json!({})).unwrap();
|
||||
let mut v: Vec<_> = m["bodies"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|b| {
|
||||
(
|
||||
b["name"].as_str().unwrap_or_default().to_string(),
|
||||
b["volume_mm3"].as_f64().unwrap(),
|
||||
b["area_mm2"].as_f64().unwrap(),
|
||||
b["faces"].clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
v
|
||||
}
|
||||
|
||||
fn errors(s: &Session) -> Vec<(u64, Option<String>)> {
|
||||
s.model.results.iter().map(|r| (r.id, r.error.clone())).collect()
|
||||
}
|
||||
|
||||
fn params(s: &Session) -> Vec<solidcraft_engine::doc::Param> {
|
||||
let mut v = s.doc.params.clone();
|
||||
v.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_designs_replay_from_their_scripts() {
|
||||
let mut replayed = 0;
|
||||
for p in corpus() {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let mut s = Session::default();
|
||||
s.execute("doc.open", &json!({"path": p.to_string_lossy()})).unwrap();
|
||||
let r = match s.execute("timeline.export_script", &json!({})) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
assert!(ASSEMBLIES.contains(&name.as_str()), "{name}: {e}");
|
||||
assert!(e.to_string().contains("not supported yet"), "{name}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
assert!(!ASSEMBLIES.contains(&name.as_str()), "{name} has components: the script should refuse it");
|
||||
// Through text, as a script file would carry it.
|
||||
let script: Value = serde_json::from_str(&r["script"].to_string()).unwrap();
|
||||
let mut t = Session::default();
|
||||
t.run_script(&script).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(t.doc.features, s.doc.features, "{name}: timeline");
|
||||
assert_eq!(params(&t), params(&s), "{name}: parameters");
|
||||
assert_eq!(t.doc.marker, s.doc.marker, "{name}: marker");
|
||||
assert_eq!(t.doc.units, s.doc.units, "{name}: units");
|
||||
assert_eq!(t.doc.sheet, s.doc.sheet, "{name}: sheet metal rules");
|
||||
assert_eq!(t.doc.plastic, s.doc.plastic, "{name}: plastic rules");
|
||||
assert_eq!(t.doc.materials, s.doc.materials, "{name}: materials");
|
||||
assert_eq!(t.doc.user_materials, s.doc.user_materials, "{name}: user materials");
|
||||
assert_eq!(t.doc.favorites, s.doc.favorites, "{name}: favourites");
|
||||
assert_eq!(t.doc.param_comments, s.doc.param_comments, "{name}: comments");
|
||||
assert_eq!(errors(&t), errors(&s), "{name}: feature results");
|
||||
assert_eq!(bodies(&mut t), bodies(&mut s), "{name}: bodies");
|
||||
replayed += 1;
|
||||
}
|
||||
assert!(replayed >= 7, "replayed {replayed} corpus designs");
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Straight edges stay one edge after many cuts (#1049). The owner's design
|
||||
//! (`tests/action_matrix/designs/crazy-geom-cuts.solidcraft`, saved by 0.10.0): five extrudes,
|
||||
//! joins and cuts, over four sketches, three of them on faces of the body with the faces'
|
||||
//! edges projected. Cuts used to leave the straight edges of the walls they sliced split at
|
||||
//! vertices nothing else used, so an edge that is one straight line was drawn (and lit on hover)
|
||||
//! as many pieces, and a sketch on such a face projected every piece.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::kernel::{FaceSurface, face_surface};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
fn design() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/action_matrix/designs/crazy-geom-cuts.solidcraft")
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
s
|
||||
}
|
||||
|
||||
/// The design built from scratch: saved without cached geometry and opened again.
|
||||
fn rebuilt() -> Session {
|
||||
let s = open(&design());
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-straight-edges-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("crazy-geom-cuts.json");
|
||||
let mut t = s.scratch();
|
||||
t.execute("file.save", &json!({"path": path.to_string_lossy(), "format": "json"})).unwrap();
|
||||
let out = open(&path);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
out
|
||||
}
|
||||
|
||||
fn errors(s: &Session) -> Vec<(u64, String)> {
|
||||
s.model.results.iter().filter_map(|r| r.error.clone().map(|e| (r.id, e))).collect()
|
||||
}
|
||||
|
||||
fn key(v: Vec3) -> [i64; 3] {
|
||||
[v.x, v.y, v.z].map(|c| (c * 1e5).round() as i64)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straight_edges_are_one_edge_after_many_cuts() {
|
||||
for (what, s) in [("as saved", open(&design())), ("built from scratch", rebuilt())] {
|
||||
assert_eq!(errors(&s), vec![], "{what}: every feature evaluates");
|
||||
let st = s.world_state();
|
||||
let bodies: Vec<_> = st.bodies.iter().filter(|b| !b.body.is_mesh()).collect();
|
||||
assert_eq!(bodies.len(), 1, "{what}: one body");
|
||||
let body = &bodies[0].body;
|
||||
let mesh = body.tessellate(0.01).unwrap();
|
||||
// Every edge is drawn as one polyline; a straight one as one segment.
|
||||
let mut at: HashMap<[i64; 3], Vec<usize>> = HashMap::new();
|
||||
for (i, e) in mesh.edges.iter().enumerate() {
|
||||
let (Some(a), Some(z)) = (e.first(), e.last()) else { panic!("{what}: edge {i} is empty") };
|
||||
at.entry(key(*a)).or_default().push(i);
|
||||
at.entry(key(*z)).or_default().push(i);
|
||||
}
|
||||
let straight = |i: usize| mesh.edges.get(i).filter(|e| e.len() == 2).and_then(|e| (e[1] - e[0]).normalized());
|
||||
let split: Vec<_> = at
|
||||
.iter()
|
||||
.filter(|(_, es)| match es.as_slice() {
|
||||
[a, b] => straight(*a).zip(straight(*b)).is_some_and(|(u, w)| u.dot(w).abs() > 1. - 1e-9),
|
||||
_ => false,
|
||||
})
|
||||
.map(|(k, _)| k.map(|c| c as f64 / 1e5))
|
||||
.collect();
|
||||
assert_eq!(split, Vec::<[f64; 3]>::new(), "{what}: straight edges split where nothing else meets them");
|
||||
// No two faces side by side in one plane.
|
||||
let plane = |f: usize| match face_surface(body, f) {
|
||||
Some(FaceSurface::Plane { normal, point }) => Some((normal, normal.dot(point))),
|
||||
_ => None,
|
||||
};
|
||||
for (i, faces) in mesh.edge_faces.iter().enumerate() {
|
||||
if let [a, b] = faces.as_slice()
|
||||
&& a != b
|
||||
&& let (Some((na, da)), Some((nb, db))) = (plane(*a as usize), plane(*b as usize))
|
||||
{
|
||||
assert!(na.dot(nb) < 1. - 1e-9 || (da - db).abs() > 1e-6, "{what}: faces {a} and {b} meet at edge {i} in one plane");
|
||||
}
|
||||
}
|
||||
assert_eq!((body.face_count(), body.edge_count(), body.vertex_count()), (35, 107, 71), "{what}: faces, edges and vertices");
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,12 @@ mod web {
|
||||
if self.files.ctx.borrow().is_none() {
|
||||
*self.files.ctx.borrow_mut() = Some(ctx.clone());
|
||||
}
|
||||
// A ready picker answer is consumed in app.logic later in this frame. Keep
|
||||
// incoming files queued until then so they cannot replace its target design.
|
||||
if self.app.file_picker_busy() {
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
files::flush(&self.files);
|
||||
for (text, error) in self.files.messages.borrow_mut().drain(..) {
|
||||
self.app.set_status(text, error);
|
||||
@@ -434,12 +440,12 @@ mod web {
|
||||
// Open shows the browser's files (and the computer's, from there).
|
||||
pick_open: Some(Box::new(move || {
|
||||
panel.panel.set(true);
|
||||
None
|
||||
Box::pin(std::future::ready(None))
|
||||
})),
|
||||
// Designs are kept in the browser; everything else is downloaded.
|
||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
||||
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
|
||||
Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))
|
||||
Box::pin(std::future::ready(Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))))
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -459,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,18 @@ license.workspace = true
|
||||
publish = false
|
||||
default-run = "solidcraft"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
# 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"
|
||||
|
||||
[lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
neg_cmp_op_on_partial_ord = "allow"
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
@@ -18,13 +28,39 @@ egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
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"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
# 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.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
+273
-21
@@ -1,6 +1,9 @@
|
||||
//! SolidCraft desktop app.
|
||||
//!
|
||||
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [design.solidcraft | part.step]`
|
||||
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]`
|
||||
//!
|
||||
//! `--safe-mode` (or `SOLIDCRAFT_SAFE_MODE=1`) starts with default settings, no recovery offer and
|
||||
//! the software renderer; see `startup` and docs/book/src/guide/troubleshooting.md.
|
||||
//!
|
||||
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
|
||||
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
|
||||
@@ -10,26 +13,46 @@
|
||||
//! See `solidcraft_ui_egui::control` and docs/control-protocol.md.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[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"))]
|
||||
mod space_mouse;
|
||||
mod startup;
|
||||
|
||||
use solidcraft_engine::{Session, io};
|
||||
use solidcraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
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 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();
|
||||
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
|
||||
@@ -38,9 +61,15 @@ impl eframe::App for App {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
solidcraft_ui_egui::documents::request_quit(&mut self.solve);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
for f in if self.solve.file_picker_busy() { Vec::new() } else { open_documents::take() } {
|
||||
self.solve.open_path_async(&f);
|
||||
}
|
||||
self.solve.logic(ctx);
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
self.space_mouse.tick(&mut self.solve, ctx);
|
||||
if !self.solve.file_picker_busy() {
|
||||
self.space_mouse.tick(&mut self.solve, ctx);
|
||||
}
|
||||
if self.solve.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
@@ -57,13 +86,23 @@ impl eframe::App for App {
|
||||
fn on_exit(&mut self) {
|
||||
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
solidcraft_engine::crash::end_session();
|
||||
// An update asked to restart into the new version or to run its installer.
|
||||
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve)
|
||||
&& let Err(e) = std::process::Command::new(&program).args(&args).spawn()
|
||||
{
|
||||
log::error!("update: starting {}: {e}", program.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
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(|| {
|
||||
rfd::FileDialog::new()
|
||||
pick_open: Some(Box::new(move || {
|
||||
let future = open_parent
|
||||
.clone()
|
||||
.add_filter(
|
||||
"Designs, STEP, IGES and meshes",
|
||||
&[
|
||||
@@ -90,11 +129,17 @@ fn services() -> Services {
|
||||
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
|
||||
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
|
||||
.add_filter("All files", &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
||||
let dialog = rfd::FileDialog::new().set_file_name(name);
|
||||
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"] {
|
||||
if name.ends_with(".json") {
|
||||
dialog.add_filter("Plain JSON design", &["json"]).add_filter("SolidCraft container", &[io::DESIGN_EXT])
|
||||
@@ -104,9 +149,12 @@ fn services() -> Services {
|
||||
} else {
|
||||
dialog.add_filter("File", exts)
|
||||
};
|
||||
dialog.save_file().map(|p| p.to_string_lossy().to_string())
|
||||
let future = dialog.save_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||
http_get: Some(std::sync::Arc::new(http_get)),
|
||||
http_download: Some(std::sync::Arc::new(http_download)),
|
||||
show_in_folder: Some(Box::new(|path| {
|
||||
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
|
||||
@@ -127,6 +175,79 @@ fn services() -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTPS GET for the update check (docs/updates.md): certificates from the operating system, a
|
||||
/// 15 s limit, at most 1 MB, and no headers beyond what GitHub requires (a User-Agent without
|
||||
/// the version or anything about this machine, and the API's media type).
|
||||
fn http_get(url: &str) -> Result<Vec<u8>, solidcraft_ui_egui::update::FetchError> {
|
||||
use solidcraft_ui_egui::update::FetchError;
|
||||
let mut response = http_call(url, std::time::Duration::from_secs(15))?;
|
||||
response.body_mut().with_config().limit(1 << 20).read_to_vec().map_err(|e| FetchError::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
/// HTTPS download of an update package to `to` (docs/updates.md), reporting progress; stops when
|
||||
/// `progress` returns false. The caller verifies the file.
|
||||
fn http_download(url: &str, to: &std::path::Path, progress: &dyn Fn(u64, Option<u64>) -> bool) -> Result<(), solidcraft_ui_egui::update::FetchError> {
|
||||
use solidcraft_ui_egui::update::FetchError;
|
||||
use std::io::{Read, Write};
|
||||
let fail = |e: String| FetchError::Failed(e);
|
||||
let mut response = http_call(url, std::time::Duration::from_secs(30 * 60))?;
|
||||
let total = response.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok());
|
||||
let mut body = response.body_mut().with_config().limit(solidcraft_ui_egui::update::install::MAX_PACKAGE).reader();
|
||||
let mut file = std::fs::File::create(to).map_err(|e| fail(format!("{}: {e}", to.display())))?;
|
||||
let mut buf = vec![0u8; 1 << 16];
|
||||
let mut done = 0u64;
|
||||
loop {
|
||||
let n = body.read(&mut buf).map_err(|e| fail(e.to_string()))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
file.write_all(buf.get(..n).unwrap_or_default()).map_err(|e| fail(format!("{}: {e}", to.display())))?;
|
||||
done += n as u64;
|
||||
if !progress(done, total) {
|
||||
return Err(fail("cancelled".into()));
|
||||
}
|
||||
}
|
||||
file.sync_all().map_err(|e| fail(format!("{}: {e}", to.display())))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One GET to GitHub: a 200 answer, or why not (rate limits included).
|
||||
fn http_call(url: &str, timeout: std::time::Duration) -> Result<ureq::http::Response<ureq::Body>, solidcraft_ui_egui::update::FetchError> {
|
||||
use solidcraft_ui_egui::update::FetchError;
|
||||
use std::time::Duration;
|
||||
let tls = ureq::tls::TlsConfig::builder()
|
||||
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
|
||||
.unversioned_rustls_crypto_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider()))
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.tls_config(tls)
|
||||
.https_only(true)
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(Some(timeout))
|
||||
.timeout_connect(Some(Duration::from_secs(5)))
|
||||
.user_agent("SolidCraft-update-check")
|
||||
.max_redirects(3)
|
||||
.build()
|
||||
.into();
|
||||
let response = agent
|
||||
.get(url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.call()
|
||||
.map_err(|e| FetchError::Failed(e.to_string()))?;
|
||||
let status = response.status().as_u16();
|
||||
let header = |k: &str| response.headers().get(k).and_then(|v| v.to_str().ok()).and_then(|v| v.trim().parse::<u64>().ok());
|
||||
if status == 429 || (status == 403 && (header("x-ratelimit-remaining") == Some(0) || header("retry-after").is_some())) {
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
||||
let retry_after_s = header("retry-after").or_else(|| header("x-ratelimit-reset").map(|reset| reset.saturating_sub(now))).unwrap_or(3600);
|
||||
return Err(FetchError::RateLimited { retry_after_s });
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(FetchError::Failed(format!("GitHub answered HTTP {status}")));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// The per-user settings folder: eframe's storage (`app.ron`) and the anti-aliasing preference.
|
||||
fn settings_dir() -> Option<std::path::PathBuf> {
|
||||
eframe::storage_dir("SolidCraft")
|
||||
@@ -174,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))
|
||||
@@ -219,7 +348,7 @@ const APP_ID: &str = solidcraft_engine::io::APP_ID;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
adopt_legacy_settings();
|
||||
let samples = msaa();
|
||||
let mut safe_mode = startup::env_on(std::env::var(startup::SAFE_MODE_ENV).ok().as_deref());
|
||||
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
@@ -228,6 +357,7 @@ fn main() -> eframe::Result {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--sample" => sample = true,
|
||||
"--safe-mode" => safe_mode = true,
|
||||
"--version" | "-V" => {
|
||||
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
@@ -246,12 +376,30 @@ fn main() -> eframe::Result {
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [design.solidcraft | part.step]");
|
||||
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]");
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
// Settings that don't read are moved aside; launches that never got a window are counted,
|
||||
// and after a few in a row this one starts in safe mode (docs/book/src/guide/troubleshooting.md).
|
||||
let settings = settings_dir();
|
||||
let mut notes: Vec<String> = Vec::new();
|
||||
let mut failed_starts = 0;
|
||||
if let Some(dir) = &settings {
|
||||
notes.extend(startup::check_settings(dir, &[solidcraft_ui_egui::prefs::STORAGE_KEY, solidcraft_ui_egui::prefs::LEGACY_STORAGE_KEY]));
|
||||
failed_starts = startup::begin(dir);
|
||||
}
|
||||
if failed_starts >= startup::FAILED_STARTS_FOR_SAFE_MODE && !safe_mode {
|
||||
safe_mode = true;
|
||||
notes.push(format!("The last {failed_starts} launches stopped before the window opened, so this one is in safe mode."));
|
||||
}
|
||||
let cpu_renderer = safe_mode || startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref());
|
||||
if safe_mode {
|
||||
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
|
||||
}
|
||||
let samples = if safe_mode { MSAA } else { msaa() };
|
||||
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
|
||||
let crashed = solidcraft_engine::crash::default_dir()
|
||||
.map(|dir| {
|
||||
@@ -263,13 +411,28 @@ 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,
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
multisampling: samples,
|
||||
// Kept with the other settings, not in a folder named after the app id.
|
||||
persistence_path: settings_dir().map(|d| d.join("app.ron")),
|
||||
// Kept with the other settings, not in a folder named after the app id. Safe mode reads
|
||||
// and writes a scratch copy, leaving the user's settings as they were.
|
||||
persistence_path: if safe_mode {
|
||||
let scratch = std::env::temp_dir().join(format!("solidcraft-safe-mode-{}.ron", std::process::id()));
|
||||
let _ = std::fs::remove_file(&scratch);
|
||||
Some(scratch)
|
||||
} else {
|
||||
settings.as_ref().map(|d| d.join("app.ron"))
|
||||
},
|
||||
persist_window: !safe_mode,
|
||||
wgpu_options: wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref()),
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
@@ -286,20 +449,35 @@ fn main() -> eframe::Result {
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// Files opened from Finder and the Dock arrive as events, not arguments.
|
||||
#[cfg(target_os = "macos")]
|
||||
open_documents::install();
|
||||
let created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let created_in_app = created.clone();
|
||||
let starting = settings.clone();
|
||||
let result = eframe::run_native(
|
||||
"SolidCraft",
|
||||
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());
|
||||
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");
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
app.cpu_meshing = safe_mode;
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
||||
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();
|
||||
@@ -320,9 +498,20 @@ fn main() -> eframe::Result {
|
||||
app.session.mark_saved();
|
||||
app.session.undo.clear();
|
||||
}
|
||||
// A launch from Finder: its files arrived before the window.
|
||||
#[cfg(target_os = "macos")]
|
||||
let files = {
|
||||
open_documents::set_ui(&cc.egui_ctx);
|
||||
[files, open_documents::take()].concat()
|
||||
};
|
||||
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() {
|
||||
@@ -333,20 +522,83 @@ fn main() -> eframe::Result {
|
||||
if let Some(dir) = solidcraft_engine::recovery::default_dir() {
|
||||
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
|
||||
solidcraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
// Safe mode keeps autosaving but offers nothing: the entries wait for a normal start.
|
||||
if safe_mode {
|
||||
app.recoverable.clear();
|
||||
}
|
||||
}
|
||||
// Tell the user about crashes since the last start (after the recovery offer is set up).
|
||||
solidcraft_ui_egui::crash_ui::announce(&mut app, &crashed);
|
||||
if !notes.is_empty() {
|
||||
app.set_status(notes.join(" "), true);
|
||||
}
|
||||
#[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,
|
||||
}))
|
||||
}),
|
||||
);
|
||||
control_server::remove_token_file();
|
||||
result
|
||||
match result {
|
||||
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, settings.as_deref()),
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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
|
||||
}
|
||||
|
||||
/// The window never opened (no graphics adapter, a device that wouldn't start): once more on
|
||||
/// the software renderer, else a message box saying what failed instead of a silent exit.
|
||||
fn no_window(error: eframe::Error, tried_cpu: bool, settings: Option<&std::path::Path>) -> eframe::Result {
|
||||
log::error!("the window could not open: {error}");
|
||||
eprintln!("SolidCraft: the window could not open: {error}");
|
||||
if !tried_cpu && let Ok(exe) = std::env::current_exe() {
|
||||
eprintln!("SolidCraft: trying again with the software renderer ({}=cpu)", startup::RENDERER_ENV);
|
||||
match std::process::Command::new(exe).args(std::env::args_os().skip(1)).env(startup::RENDERER_ENV, "cpu").status() {
|
||||
Ok(status) if status.success() => return Ok(()),
|
||||
// The second try showed its own message.
|
||||
Ok(_) => return Err(error),
|
||||
Err(e) => log::error!("could not start again with the software renderer: {e}"),
|
||||
}
|
||||
}
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title("SolidCraft could not start")
|
||||
.set_description(startup::no_window_message(&error.to_string(), settings))
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show();
|
||||
Err(error)
|
||||
}
|
||||
|
||||
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! macOS: files opened from Finder (double-click, Open With, a drop on the Dock icon) or with
|
||||
//! `open -a SolidCraft file` (#997).
|
||||
//!
|
||||
//! macOS doesn't pass these on the command line: AppKit hands them to the application delegate's
|
||||
//! `application:openURLs:`, and when the delegate has no such method it asks `NSDocumentController`,
|
||||
//! which fails with "SolidCraft cannot open files in the … format" because SolidCraft isn't an
|
||||
//! `NSDocument` app. winit 0.30's delegate doesn't implement the method, so [`install`] adds it to
|
||||
//! the delegate's class when the app is about to finish launching: after winit set the delegate and
|
||||
//! before AppKit delivers the files the app was launched with. The handler queues the paths and
|
||||
//! wakes the UI, which opens them with [`take`] like files named on the command line.
|
||||
//!
|
||||
//! Adding a method to another crate's Objective-C class has no safe API, hence the `unsafe_code`
|
||||
//! allowance on [`install`] and [`add_open_urls_method`] (winit overrides `sendEvent:` on
|
||||
//! `NSApplication` similarly).
|
||||
|
||||
use std::ptr::NonNull;
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2::runtime::{AnyObject, Imp, Sel};
|
||||
use objc2::sel;
|
||||
use objc2_app_kit::{NSApplication, NSApplicationWillFinishLaunchingNotification};
|
||||
use objc2_foundation::{NSArray, NSNotification, NSNotificationCenter, NSURL};
|
||||
|
||||
/// Paths that arrived and haven't been opened yet.
|
||||
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
|
||||
static UI: OnceLock<egui::Context> = OnceLock::new();
|
||||
|
||||
/// Handle open-document events from now on. Call before the event loop runs.
|
||||
#[allow(unsafe_code)]
|
||||
pub fn install() {
|
||||
let block = RcBlock::new(|_: NonNull<NSNotification>| add_open_urls_method());
|
||||
// SAFETY: the name is AppKit's notification constant; no object or queue filter, so the block
|
||||
// runs on the posting thread (AppKit posts it on the main thread). The block captures nothing,
|
||||
// so it is sendable. The notification center keeps the registration (and the block) for the
|
||||
// life of the app.
|
||||
unsafe {
|
||||
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
|
||||
Some(NSApplicationWillFinishLaunchingNotification),
|
||||
None,
|
||||
None,
|
||||
&block,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake `ctx` when files arrive while the app runs.
|
||||
pub fn set_ui(ctx: &egui::Context) {
|
||||
// Set once; a second call would hand over the same context.
|
||||
let _ = UI.set(ctx.clone());
|
||||
}
|
||||
|
||||
/// The paths that arrived since the last call.
|
||||
pub fn take() -> Vec<String> {
|
||||
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
|
||||
}
|
||||
|
||||
/// `application:openURLs:` on the application delegate's class.
|
||||
#[allow(unsafe_code)]
|
||||
fn add_open_urls_method() {
|
||||
let Some(mtm) = MainThreadMarker::new() else { return };
|
||||
let Some(delegate) = NSApplication::sharedApplication(mtm).delegate() else {
|
||||
log::warn!("no application delegate; files opened from Finder won't open");
|
||||
return;
|
||||
};
|
||||
let class = AsRef::<AnyObject>::as_ref(&*delegate).class();
|
||||
let handler: extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>) = open_urls;
|
||||
// SAFETY: `Imp` is the untyped form of a method implementation; the Objective-C runtime calls
|
||||
// it with the arguments the type encoding declares, which match `open_urls`: no return value
|
||||
// (`v`), the receiver (`@`), the selector (`:`), the application (`@`) and the URL array (`@`).
|
||||
let added = unsafe {
|
||||
let imp = std::mem::transmute::<extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>), Imp>(handler);
|
||||
objc2::ffi::class_addMethod(std::ptr::from_ref(class).cast_mut(), sel!(application:openURLs:), imp, c"v@:@@".as_ptr())
|
||||
};
|
||||
if !added.as_bool() {
|
||||
log::warn!("the application delegate already handles open-document events");
|
||||
}
|
||||
}
|
||||
|
||||
/// AppKit calls this; a panic stops here instead of unwinding through AppKit's frames.
|
||||
extern "C-unwind" fn open_urls(_delegate: &AnyObject, _cmd: Sel, _app: &AnyObject, urls: &NSArray<NSURL>) {
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| queue(urls))).is_err() {
|
||||
log::warn!("files opened from Finder were dropped after an internal error");
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue the files among `urls` and wake the UI.
|
||||
fn queue(urls: &NSArray<NSURL>) {
|
||||
// Only file URLs: macOS 27 turns an https URL's path into a file path too.
|
||||
let paths = urls.iter().filter(|u| u.isFileURL()).filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned());
|
||||
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(paths);
|
||||
if let Some(ctx) = UI.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use objc2::runtime::NSObject;
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Files queue until the UI takes them; URLs that aren't files are left out.
|
||||
#[test]
|
||||
fn opened_files_wait_for_the_ui() {
|
||||
let web = NSURL::URLWithString(&NSString::from_str("https://example.com/a.solidcraft")).unwrap();
|
||||
let urls = NSArray::from_retained_slice(&[
|
||||
NSURL::from_file_path("/tmp/a b.solidcraft").unwrap(),
|
||||
web,
|
||||
NSURL::from_file_path("/tmp/c.step").unwrap(),
|
||||
]);
|
||||
let any = NSObject::new();
|
||||
open_urls(&any, sel!(application:openURLs:), &any, &urls);
|
||||
assert_eq!(take(), ["/tmp/a b.solidcraft", "/tmp/c.step"]);
|
||||
assert!(take().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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#"
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Getting the window open, whatever state the settings folder or the graphics device is in
|
||||
//! (docs/book/src/guide/troubleshooting.md).
|
||||
//!
|
||||
//! - **Settings that don't read** (`app.ron`, `graphics.json`): moved aside as
|
||||
//! `<name>.bad-<time>` and the app starts with defaults, instead of a newer or damaged file
|
||||
//! being dropped silently or stopping the start.
|
||||
//! - **Launches that die before the window is up**: a marker file counts them; after two in a
|
||||
//! row the next launch starts in safe mode by itself.
|
||||
//! - **Safe mode** (`--safe-mode` or `SOLIDCRAFT_SAFE_MODE=1`): default settings (the saved ones
|
||||
//! are left untouched), no recovery offer, the software renderer and CPU meshing.
|
||||
//! - **No usable graphics device**: one retry on the software renderer
|
||||
//! (`SOLIDCRAFT_RENDERER=cpu`), then a message box saying what failed and what to try.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The environment variable that starts safe mode (any value but `0` or empty).
|
||||
pub const SAFE_MODE_ENV: &str = "SOLIDCRAFT_SAFE_MODE";
|
||||
/// `SOLIDCRAFT_RENDERER=cpu` picks the software renderer (WARP, lavapipe, llvmpipe).
|
||||
pub const RENDERER_ENV: &str = "SOLIDCRAFT_RENDERER";
|
||||
/// Consecutive launches that died before the window was up, after which safe mode starts.
|
||||
pub const FAILED_STARTS_FOR_SAFE_MODE: u32 = 2;
|
||||
const MARKER: &str = "starting";
|
||||
|
||||
/// Whether an environment value switches something on.
|
||||
pub fn env_on(v: Option<&str>) -> bool {
|
||||
v.map(str::trim).is_some_and(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
|
||||
}
|
||||
|
||||
/// Whether `SOLIDCRAFT_RENDERER` asks for the software renderer.
|
||||
pub fn cpu_renderer_env(v: Option<&str>) -> bool {
|
||||
v.map(str::trim).is_some_and(|v| v.eq_ignore_ascii_case("cpu") || v.eq_ignore_ascii_case("software"))
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
/// Move `path` aside as `<name>.bad-<time>` (kept for a bug report); returns where it went.
|
||||
pub fn back_up(path: &Path) -> std::io::Result<PathBuf> {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "settings".into());
|
||||
let mut to = path.with_file_name(format!("{name}.bad-{}", unix_now()));
|
||||
let mut n = 1;
|
||||
while to.exists() && n < 100 {
|
||||
to = path.with_file_name(format!("{name}.bad-{}-{n}", unix_now()));
|
||||
n += 1;
|
||||
}
|
||||
std::fs::rename(path, &to)?;
|
||||
Ok(to)
|
||||
}
|
||||
|
||||
/// Whether eframe's `app.ron` reads, with the preferences inside it.
|
||||
fn app_ron_reads(text: &str, prefs_keys: &[&str]) -> bool {
|
||||
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
|
||||
prefs_keys.iter().filter_map(|k| kv.get(*k)).all(|p| serde_json::from_str::<serde_json::Value>(p).is_ok_and(|v| v.is_object()))
|
||||
}
|
||||
|
||||
/// Check the settings files in `dir` before anything reads them: one that doesn't read is moved
|
||||
/// aside (the app then starts with defaults for it). Returns a line per file moved.
|
||||
pub fn check_settings(dir: &Path, prefs_keys: &[&str]) -> Vec<String> {
|
||||
let mut notes = Vec::new();
|
||||
let mut check = |name: &str, reads: &dyn Fn(&str) -> bool| {
|
||||
let path = dir.join(name);
|
||||
let Ok(bytes) = std::fs::read(&path) else { return };
|
||||
let ok = std::str::from_utf8(&bytes).is_ok_and(|t| t.trim().is_empty() || reads(t));
|
||||
if ok {
|
||||
return;
|
||||
}
|
||||
match back_up(&path) {
|
||||
Ok(to) => {
|
||||
log::warn!("settings: {} did not read; moved to {}", path.display(), to.display());
|
||||
notes.push(format!("{name} could not be read and was moved to {}; defaults are used.", to.display()));
|
||||
}
|
||||
Err(e) => log::warn!("settings: {} did not read and could not be moved: {e}", path.display()),
|
||||
}
|
||||
};
|
||||
check("app.ron", &|t| app_ron_reads(t, prefs_keys));
|
||||
check("graphics.json", &|t| serde_json::from_str::<serde_json::Value>(t).is_ok_and(|v| v.is_object()));
|
||||
notes
|
||||
}
|
||||
|
||||
/// A launch begins: count it in the marker in `dir`. Returns how many launches before this one
|
||||
/// died before their window was up.
|
||||
pub fn begin(dir: &Path) -> u32 {
|
||||
let marker = dir.join(MARKER);
|
||||
let before = std::fs::read_to_string(&marker).ok().and_then(|s| s.trim().parse::<u32>().ok()).unwrap_or(0).min(1000);
|
||||
if std::fs::create_dir_all(dir).is_ok() {
|
||||
let _ = std::fs::write(&marker, (before + 1).to_string());
|
||||
}
|
||||
before
|
||||
}
|
||||
|
||||
/// The window is up: this launch did not fail to start.
|
||||
pub fn started(dir: &Path) {
|
||||
let _ = std::fs::remove_file(dir.join(MARKER));
|
||||
}
|
||||
|
||||
/// The software adapter among `adapters` (WARP on Windows, lavapipe or llvmpipe elsewhere), else
|
||||
/// the first one that can draw to `surface`.
|
||||
pub fn pick_cpu_adapter(adapters: &[eframe::wgpu::Adapter], surface: Option<&eframe::wgpu::Surface<'_>>) -> Result<eframe::wgpu::Adapter, String> {
|
||||
let fits = |a: &&eframe::wgpu::Adapter| surface.is_none_or(|s| a.is_surface_supported(s));
|
||||
adapters
|
||||
.iter()
|
||||
.filter(fits)
|
||||
.find(|a| a.get_info().device_type == eframe::wgpu::DeviceType::Cpu)
|
||||
.or_else(|| adapters.iter().find(fits))
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
let names: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().backend)).collect();
|
||||
format!("no graphics adapter can draw the window (found: {})", if names.is_empty() { "none".into() } else { names.join(", ") })
|
||||
})
|
||||
}
|
||||
|
||||
/// `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());
|
||||
format!(
|
||||
"SolidCraft could not open its window.\n\n{error}\n\n\
|
||||
The software renderer was tried too. Things to try:\n\
|
||||
- Install or update the graphics driver (on Windows, from the GPU maker's site rather than Windows Update).\n\
|
||||
- Over Remote Desktop or in a virtual machine, enable 3D acceleration or run it on the machine itself.\n\
|
||||
- Start it once with `solidcraft --safe-mode`.\n\
|
||||
- Rename the settings folder: {folder}\n\n\
|
||||
More: https://bherbruck.github.io/solidcraft/guide/troubleshooting.html"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-startup-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_read_from_the_environment() {
|
||||
assert!(env_on(Some("1")) && env_on(Some("yes")) && !env_on(Some("0")) && !env_on(Some(" ")) && !env_on(None) && !env_on(Some("false")));
|
||||
assert!(cpu_renderer_env(Some("cpu")) && cpu_renderer_env(Some("Software")) && !cpu_renderer_env(Some("gpu")) && !cpu_renderer_env(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_that_do_not_read_are_moved_aside_and_good_ones_stay() {
|
||||
let keys = ["solidcraft.prefs", "solvecraft.prefs"];
|
||||
let d = scratch("check");
|
||||
// Good files (and an empty one) stay where they are.
|
||||
std::fs::write(d.join("app.ron"), r#"{"solidcraft.prefs": "{\"grid\":true}", "egui": "(anything)"}"#).unwrap();
|
||||
std::fs::write(d.join("graphics.json"), r#"{"msaa":4}"#).unwrap();
|
||||
assert!(check_settings(&d, &keys).is_empty());
|
||||
assert!(d.join("app.ron").is_file() && d.join("graphics.json").is_file());
|
||||
// Damaged RON, preferences that aren't JSON, a truncated graphics.json, non-UTF-8 bytes.
|
||||
for (name, text) in [
|
||||
("app.ron", b"{\"solidcraft.prefs\": \"{\\\"gr".as_slice()),
|
||||
("app.ron", br#"{"solvecraft.prefs": "not json"}"#.as_slice()),
|
||||
("graphics.json", br#"{"msaa":"#.as_slice()),
|
||||
("graphics.json", b"\xff\xfe\x00".as_slice()),
|
||||
] {
|
||||
std::fs::write(d.join(name), text).unwrap();
|
||||
let notes = check_settings(&d, &keys);
|
||||
assert_eq!(notes.len(), 1, "{name}: {notes:?}");
|
||||
assert!(!d.join(name).exists(), "{name} moved");
|
||||
let kept: Vec<_> =
|
||||
std::fs::read_dir(&d).unwrap().flatten().filter(|e| e.file_name().to_string_lossy().starts_with(&format!("{name}.bad-"))).collect();
|
||||
assert!(kept.iter().any(|e| std::fs::read(e.path()).unwrap() == text), "{name}: the bad file is kept");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launches_that_never_got_a_window_are_counted_until_one_does() {
|
||||
let d = scratch("marker");
|
||||
assert_eq!(begin(&d), 0);
|
||||
assert_eq!(begin(&d), 1);
|
||||
assert_eq!(begin(&d), FAILED_STARTS_FOR_SAFE_MODE);
|
||||
started(&d);
|
||||
assert_eq!(begin(&d), 0);
|
||||
// A damaged marker counts as none.
|
||||
std::fs::write(d.join(MARKER), "zz").unwrap();
|
||||
assert_eq!(begin(&d), 0);
|
||||
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")));
|
||||
assert!(m.contains("no adapter") && m.contains("/x/SolidCraft") && m.contains("--safe-mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_software_adapter_is_preferred_and_its_absence_is_an_error_not_a_panic() {
|
||||
assert!(pick_cpu_adapter(&[], None).unwrap_err().contains("none"));
|
||||
let instance = eframe::wgpu::Instance::new(eframe::wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapters = pollster::block_on(instance.enumerate_adapters(eframe::wgpu::Backends::all()));
|
||||
if adapters.is_empty() {
|
||||
return;
|
||||
}
|
||||
let picked = pick_cpu_adapter(&adapters, None).unwrap();
|
||||
if adapters.iter().any(|a| a.get_info().device_type == eframe::wgpu::DeviceType::Cpu) {
|
||||
assert_eq!(picked.get_info().device_type, eframe::wgpu::DeviceType::Cpu);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -266,6 +265,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.line(&[(17.0, y), (21.0, y)], ink);
|
||||
}
|
||||
}
|
||||
// Update: an arrow down onto a tray (a newer release can be downloaded).
|
||||
"update" => {
|
||||
pen.line(&[(12.0, 3.5), (12.0, 14.5)], ink);
|
||||
pen.line(&[(7.5, 10.0), (12.0, 14.5), (16.5, 10.0)], ink);
|
||||
pen.line(&[(4.0, 15.0), (4.0, 20.0), (20.0, 20.0), (20.0, 15.0)], ink);
|
||||
}
|
||||
// Help: a question mark in a ring.
|
||||
"help" => {
|
||||
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
|
||||
@@ -472,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);
|
||||
@@ -519,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" => {
|
||||
@@ -527,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);
|
||||
@@ -566,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" => {
|
||||
@@ -584,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);
|
||||
@@ -819,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);
|
||||
@@ -828,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" => {
|
||||
@@ -839,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -977,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);
|
||||
}
|
||||
@@ -993,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);
|
||||
@@ -1036,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);
|
||||
@@ -1047,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);
|
||||
@@ -1064,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);
|
||||
@@ -1074,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);
|
||||
@@ -1084,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);
|
||||
@@ -1098,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);
|
||||
@@ -1134,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);
|
||||
@@ -1154,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);
|
||||
}
|
||||
@@ -1184,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);
|
||||
}
|
||||
@@ -1203,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);
|
||||
}
|
||||
@@ -1250,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.
|
||||
@@ -1277,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);
|
||||
}
|
||||
@@ -1352,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);
|
||||
}
|
||||
|
||||
+210
-1
@@ -1,10 +1,22 @@
|
||||
{
|
||||
"Send your report on GitHub": "Bericht auf GitHub senden",
|
||||
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Unten den GitHub-Entwurf öffnen und den kopierten Bericht aus der Zwischenablage einfügen.",
|
||||
"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",
|
||||
@@ -37,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",
|
||||
@@ -67,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",
|
||||
@@ -260,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",
|
||||
@@ -271,9 +312,33 @@
|
||||
"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",
|
||||
"ui.orientation": "Ausrichtung",
|
||||
"ui.twist": "Verdrehung",
|
||||
"ui.end_scale": "Endskalierung",
|
||||
"ui.sweep_turn_with_path": "Mit Pfad drehen",
|
||||
"ui.sweep_keep_as_drawn": "Wie gezeichnet",
|
||||
"ui.sweep_follow_direction": "Richtung folgen",
|
||||
"ui.section_analysis": "Querschnittsanalyse",
|
||||
"ui.select": "Auswählen",
|
||||
"ui.selection": "Auswahl",
|
||||
@@ -361,6 +426,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",
|
||||
@@ -384,6 +460,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",
|
||||
@@ -456,6 +535,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",
|
||||
@@ -469,6 +551,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",
|
||||
@@ -495,6 +578,8 @@
|
||||
"cmd.material_delete": "Physisches Material löschen",
|
||||
"cmd.material_edit": "Physisches Material bearbeiten",
|
||||
"cmd.material_list": "Materialien auflisten",
|
||||
"cmd.material_properties_directional": "Richtungsabhängiger Elastizitätsmodul",
|
||||
"cmd.material_properties_validate": "Physikalische Materialdaten prüfen",
|
||||
"cmd.mesh_convert": "Netz konvertieren",
|
||||
"cmd.model_edges": "Kanten auflisten",
|
||||
"cmd.model_faces": "Flächen auflisten",
|
||||
@@ -515,6 +600,7 @@
|
||||
"cmd.parameters_delete": "Parameter löschen",
|
||||
"cmd.parameters_export": "Parameter exportieren",
|
||||
"cmd.parameters_favorite": "Favorisierter Parameter",
|
||||
"cmd.parameters_move": "Parameter verschieben",
|
||||
"cmd.parameters_graph": "Parametergraph",
|
||||
"cmd.parameters_import": "Parameter importieren",
|
||||
"cmd.parameters_list": "Parameter auflisten",
|
||||
@@ -540,6 +626,7 @@
|
||||
"cmd.sheet_fold": "Falten",
|
||||
"cmd.sheet_hem": "Umkantung",
|
||||
"cmd.sheet_manage_rules": "Blechregeln",
|
||||
"cmd.sheet_perforate": "Perforieren",
|
||||
"cmd.sheet_refold": "Wieder falten",
|
||||
"cmd.sheet_rules": "Blechregeln auflisten",
|
||||
"cmd.sheet_unfold": "Entfalten",
|
||||
@@ -637,9 +724,11 @@
|
||||
"cmd.surface_unstitch": "Verbund aufheben",
|
||||
"cmd.surface_reverse_normal": "Normale umkehren",
|
||||
"cmd.inspect_continuity": "Kontinuitätsprüfung",
|
||||
"cmd.timeline_add": "Aufgezeichnetes Merkmal hinzufügen",
|
||||
"cmd.timeline_compute_all": "Alles berechnen",
|
||||
"cmd.timeline_copy": "Merkmale kopieren",
|
||||
"cmd.timeline_dependents": "Merkmalsabhängigkeiten",
|
||||
"cmd.timeline_export_script": "Befehlsskript exportieren",
|
||||
"cmd.timeline_inspect": "Merkmal prüfen",
|
||||
"cmd.timeline_paste": "Merkmale einfügen",
|
||||
"cmd.timeline_redefine": "Merkmal bearbeiten",
|
||||
@@ -658,9 +747,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)",
|
||||
@@ -697,5 +789,122 @@
|
||||
"sketch_text.align_right": "Rechts",
|
||||
"sketch_text.path_hint": "Eine Skizzenkurve, der der Text folgt: hier klicken, dann die Kurve",
|
||||
"sketch_text.path_clear": "Text von der Kurve lösen",
|
||||
"sketch_text.on_path": "Der Text folgt seiner Kurve: sie bestimmt seine Lage"
|
||||
"sketch_text.on_path": "Der Text folgt seiner Kurve: sie bestimmt seine Lage",
|
||||
"cmd.library_inspect": "Lokale Bibliothek prüfen",
|
||||
"cmd.library_export": "Designbibliothek exportieren",
|
||||
"cmd.library_apply": "Lokalen Bibliothekseintrag anwenden",
|
||||
"Thin": "Dünnwandig",
|
||||
"Thin Extrude": "Dünnwandig extrudieren",
|
||||
"Wall Location": "Wandlage",
|
||||
"Wall Thickness": "Wandstärke",
|
||||
"Thickness Outside": "Dicke außen",
|
||||
"Inside": "Innen",
|
||||
"Outside": "Außen",
|
||||
"Profiles/Curves": "Profile/Kurven",
|
||||
"update.button": "Update",
|
||||
"update.button_tip": "SolidCraft {version} ist verfügbar",
|
||||
"update.title": "Update verfügbar",
|
||||
"update.available": "SolidCraft {version} ist verfügbar",
|
||||
"update.you_have": "Installiert ist {version}.",
|
||||
"update.no_notes": "Zu diesem Release gibt es keine Hinweise.",
|
||||
"update.system_hint": "Diese Installation ist ein {kind}. Aktualisieren mit:",
|
||||
"update.package_hint": "Diese Installation stammt aus einem {kind}. Neues Paket herunterladen und installieren mit:",
|
||||
"update.copy": "Kopieren",
|
||||
"update.file": "Herunterladen holt {file}.",
|
||||
"update.download": "Herunterladen",
|
||||
"update.release_page": "Release-Seite öffnen",
|
||||
"update.skip": "Diese Version überspringen",
|
||||
"update.later": "Später erinnern",
|
||||
"update.prefs_label": "Updates",
|
||||
"update.check_automatically": "Automatisch nach Updates suchen",
|
||||
"update.check_now": "Jetzt suchen",
|
||||
"update.checking": "Suche läuft…",
|
||||
"update.failed": "Suche fehlgeschlagen: {error}",
|
||||
"update.up_to_date": "SolidCraft ist aktuell.",
|
||||
"update.prefs_note": "Dies ist SolidCraft {version} ({kind}). Die Suche fragt GitHub nach dem neuesten Release und sendet sonst nichts.",
|
||||
"update.prefs_dev": "Dies ist ein Entwicklungs-Build ({version}): er sucht nicht nach Updates.",
|
||||
"update.show": "Update auf {version} anzeigen",
|
||||
"update.prefs_flatpak": "Flatpak aktualisiert diese Installation ({version}): {command} ausführen oder die Softwareverwaltung nutzen. SolidCraft sucht nicht selbst.",
|
||||
"update.update_now": "Jetzt aktualisieren",
|
||||
"update.install_msi": "Installieren (schließt SolidCraft)",
|
||||
"update.download_open": "Herunterladen und öffnen",
|
||||
"update.downloading": "Herunterladen und prüfen… {done} von {total} MB",
|
||||
"update.installed": "SolidCraft {version} ist installiert. Zum Verwenden neu starten.",
|
||||
"update.restart": "Jetzt neu starten",
|
||||
"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.",
|
||||
"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",
|
||||
"joint.one_component": "Diese Teile sind Körper einer Komponente: {bodies}. Ein Gelenk bewegt eine Komponente gegenüber einer anderen, daher braucht jedes Teil eine eigene Komponente.",
|
||||
"joint.one_body": "Beide Auswahlen liegen auf {body}. Wählen Sie die zweite auf einem anderen Teil.",
|
||||
"joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist."
|
||||
}
|
||||
|
||||
+207
-1
@@ -1,4 +1,15 @@
|
||||
{
|
||||
"Send your report on GitHub": "Send your report on GitHub",
|
||||
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Open GitHub draft below, then paste the report copied to your clipboard.",
|
||||
"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})",
|
||||
@@ -77,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",
|
||||
@@ -90,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",
|
||||
@@ -136,6 +151,7 @@
|
||||
"cmd.parameters_delete": "Delete Parameter",
|
||||
"cmd.parameters_export": "Export Parameters",
|
||||
"cmd.parameters_favorite": "Favourite Parameter",
|
||||
"cmd.parameters_move": "Move Parameter",
|
||||
"cmd.parameters_graph": "Parameter Graph",
|
||||
"cmd.parameters_import": "Import Parameters",
|
||||
"cmd.parameters_list": "List Parameters",
|
||||
@@ -161,6 +177,7 @@
|
||||
"cmd.sheet_fold": "Fold",
|
||||
"cmd.sheet_hem": "Hem",
|
||||
"cmd.sheet_manage_rules": "Sheet Metal Rules",
|
||||
"cmd.sheet_perforate": "Perforate",
|
||||
"cmd.sheet_refold": "Refold",
|
||||
"cmd.sheet_rules": "List Sheet Metal Rules",
|
||||
"cmd.sheet_unfold": "Unfold",
|
||||
@@ -259,9 +276,11 @@
|
||||
"cmd.surface_unstitch": "Unstitch",
|
||||
"cmd.surface_reverse_normal": "Reverse Normal",
|
||||
"cmd.inspect_continuity": "Continuity Analysis",
|
||||
"cmd.timeline_add": "Add Recorded Feature",
|
||||
"cmd.timeline_compute_all": "Compute All",
|
||||
"cmd.timeline_copy": "Copy Features",
|
||||
"cmd.timeline_dependents": "Feature Dependents",
|
||||
"cmd.timeline_export_script": "Export Command Script",
|
||||
"cmd.timeline_inspect": "Inspect Feature",
|
||||
"cmd.timeline_paste": "Paste Features",
|
||||
"cmd.timeline_redefine": "Edit Feature",
|
||||
@@ -275,6 +294,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",
|
||||
@@ -309,6 +329,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",
|
||||
@@ -342,6 +372,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",
|
||||
@@ -553,6 +598,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",
|
||||
@@ -567,9 +616,33 @@
|
||||
"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",
|
||||
"ui.orientation": "Orientation",
|
||||
"ui.twist": "Twist",
|
||||
"ui.end_scale": "End Scale",
|
||||
"ui.sweep_turn_with_path": "Turn with Path",
|
||||
"ui.sweep_keep_as_drawn": "Keep as Drawn",
|
||||
"ui.sweep_follow_direction": "Follow Direction",
|
||||
"ui.section_analysis": "Section Analysis",
|
||||
"ui.select": "Select",
|
||||
"ui.select_other_hint": "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.",
|
||||
@@ -721,6 +794,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",
|
||||
@@ -909,6 +984,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",
|
||||
@@ -932,6 +1018,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",
|
||||
@@ -957,5 +1046,122 @@
|
||||
"Line Spacing": "Line Spacing",
|
||||
"Path": "Path",
|
||||
"sketch_text.on_path": "The text follows its path: the curve places it",
|
||||
"cmd.material_properties_validate":"Validate Physical Material Data"
|
||||
"cmd.library_inspect": "Inspect Local Library",
|
||||
"cmd.library_export": "Export Design Library",
|
||||
"cmd.library_apply": "Apply Local Library Entry",
|
||||
"Thin": "Thin",
|
||||
"Thin Extrude": "Thin Extrude",
|
||||
"Wall Location": "Wall Location",
|
||||
"Wall Thickness": "Wall Thickness",
|
||||
"Thickness Outside": "Thickness Outside",
|
||||
"Inside": "Inside",
|
||||
"Outside": "Outside",
|
||||
"Profiles/Curves": "Profiles/Curves",
|
||||
"cmd.material_properties_validate": "Validate Physical Material Data",
|
||||
"cmd.material_properties_directional": "Directional Elastic Modulus",
|
||||
"update.button": "Update",
|
||||
"update.button_tip": "SolidCraft {version} is available",
|
||||
"update.title": "Update available",
|
||||
"update.available": "SolidCraft {version} is available",
|
||||
"update.you_have": "You have {version}.",
|
||||
"update.no_notes": "This release has no notes.",
|
||||
"update.system_hint": "This copy is a {kind}. Update it with:",
|
||||
"update.package_hint": "This copy came from a {kind}. Download the new package, then install it with:",
|
||||
"update.copy": "Copy",
|
||||
"update.file": "Download gets {file}.",
|
||||
"update.download": "Download",
|
||||
"update.release_page": "Open release page",
|
||||
"update.skip": "Skip this version",
|
||||
"update.later": "Remind me later",
|
||||
"update.prefs_label": "Updates",
|
||||
"update.check_automatically": "Check for updates automatically",
|
||||
"update.check_now": "Check now",
|
||||
"update.checking": "Checking…",
|
||||
"update.failed": "Couldn't check: {error}",
|
||||
"update.up_to_date": "SolidCraft is up to date.",
|
||||
"update.prefs_note": "This is SolidCraft {version} ({kind}). A check asks GitHub for the latest release and sends nothing else.",
|
||||
"update.prefs_dev": "This is a development build ({version}): it doesn't check for updates.",
|
||||
"update.show": "Show the update to {version}",
|
||||
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software center. SolidCraft doesn't check.",
|
||||
"update.update_now": "Update now",
|
||||
"update.install_msi": "Install (closes SolidCraft)",
|
||||
"update.download_open": "Download and open",
|
||||
"update.downloading": "Downloading and verifying… {done} of {total} MB",
|
||||
"update.installed": "SolidCraft {version} is installed. Restart to use it.",
|
||||
"update.restart": "Restart now",
|
||||
"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.",
|
||||
"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",
|
||||
"joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.",
|
||||
"joint.one_body": "Both picks are on {body}. Pick the second one on another part.",
|
||||
"joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is."
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+207
-1
@@ -1,4 +1,15 @@
|
||||
{
|
||||
"Send your report on GitHub": "GitHubでレポートを送信",
|
||||
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 下のボタンでGitHubの下書きを開き、クリップボードにコピーされたレポートを貼り付けます。",
|
||||
"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})",
|
||||
@@ -77,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": "コマンドを一覧表示",
|
||||
@@ -90,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": "曲率コーム解析",
|
||||
@@ -136,6 +151,7 @@
|
||||
"cmd.parameters_delete": "パラメータを削除",
|
||||
"cmd.parameters_export": "パラメータをエクスポート",
|
||||
"cmd.parameters_favorite": "お気に入りパラメータ",
|
||||
"cmd.parameters_move": "パラメータを移動",
|
||||
"cmd.parameters_graph": "パラメータ グラフ",
|
||||
"cmd.parameters_import": "パラメータをインポート",
|
||||
"cmd.parameters_list": "パラメータを一覧表示",
|
||||
@@ -161,6 +177,7 @@
|
||||
"cmd.sheet_fold": "折り曲げ",
|
||||
"cmd.sheet_hem": "ヘミング",
|
||||
"cmd.sheet_manage_rules": "板金ルール",
|
||||
"cmd.sheet_perforate": "パンチング",
|
||||
"cmd.sheet_refold": "折り戻し",
|
||||
"cmd.sheet_rules": "板金ルールを一覧表示",
|
||||
"cmd.sheet_unfold": "展開",
|
||||
@@ -259,9 +276,11 @@
|
||||
"cmd.surface_unstitch": "縫い合わせ解除",
|
||||
"cmd.surface_reverse_normal": "法線を反転",
|
||||
"cmd.inspect_continuity": "連続性解析",
|
||||
"cmd.timeline_add": "記録したフィーチャーを追加",
|
||||
"cmd.timeline_compute_all": "すべて再計算",
|
||||
"cmd.timeline_copy": "フィーチャーをコピー",
|
||||
"cmd.timeline_dependents": "従属フィーチャー",
|
||||
"cmd.timeline_export_script": "コマンド スクリプトをエクスポート",
|
||||
"cmd.timeline_inspect": "フィーチャーを検査",
|
||||
"cmd.timeline_paste": "フィーチャーを貼り付け",
|
||||
"cmd.timeline_redefine": "フィーチャーを編集",
|
||||
@@ -275,6 +294,7 @@
|
||||
"cmd.view_list": "名前付きビューを一覧表示",
|
||||
"cmd.view_rename": "名前付きビューの名前を変更",
|
||||
"cmd.view_save": "新規名前付きビュー",
|
||||
"dialog.computing": "計算中… {seconds} 秒",
|
||||
"dialog.custom_axis": "↖ カスタム軸",
|
||||
"dialog.edit_title": "{title}を編集",
|
||||
"dialog.select": "↖ 選択",
|
||||
@@ -309,6 +329,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": "アセンブリ",
|
||||
@@ -342,6 +372,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": "同心",
|
||||
@@ -553,6 +598,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": "矩形パターン",
|
||||
@@ -567,9 +616,33 @@
|
||||
"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": "尺度",
|
||||
"ui.orientation": "向き",
|
||||
"ui.twist": "ねじれ",
|
||||
"ui.end_scale": "終端スケール",
|
||||
"ui.sweep_turn_with_path": "パスに沿って回転",
|
||||
"ui.sweep_keep_as_drawn": "描いた向きを保持",
|
||||
"ui.sweep_follow_direction": "方向に従う",
|
||||
"ui.section_analysis": "断面解析",
|
||||
"ui.select": "選択",
|
||||
"ui.select_other_hint": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。",
|
||||
@@ -721,6 +794,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": "フォント",
|
||||
@@ -909,6 +984,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 +1018,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 +1046,122 @@
|
||||
"Line Spacing": "行間",
|
||||
"Path": "パス",
|
||||
"sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります",
|
||||
"cmd.material_properties_validate":"物理材料データを検証"
|
||||
"cmd.library_inspect": "ローカルライブラリを確認",
|
||||
"cmd.library_export": "デザインライブラリを書き出す",
|
||||
"cmd.library_apply": "ローカルライブラリ項目を適用",
|
||||
"Thin": "薄肉",
|
||||
"Thin Extrude": "薄肉押し出し",
|
||||
"Wall Location": "壁の位置",
|
||||
"Wall Thickness": "壁の厚さ",
|
||||
"Thickness Outside": "外側の厚さ",
|
||||
"Inside": "内側",
|
||||
"Outside": "外側",
|
||||
"Profiles/Curves": "プロファイル/曲線",
|
||||
"cmd.material_properties_validate": "物理材料データを検証",
|
||||
"cmd.material_properties_directional": "方向別弾性率",
|
||||
"update.button": "アップデート",
|
||||
"update.button_tip": "SolidCraft {version} が利用できます",
|
||||
"update.title": "アップデートがあります",
|
||||
"update.available": "SolidCraft {version} が利用できます",
|
||||
"update.you_have": "現在のバージョンは {version} です。",
|
||||
"update.no_notes": "このリリースにはリリースノートがありません。",
|
||||
"update.system_hint": "このコピーは {kind} です。次のコマンドで更新します:",
|
||||
"update.package_hint": "このコピーは {kind} からインストールされました。新しいパッケージをダウンロードし、次のコマンドでインストールします:",
|
||||
"update.copy": "コピー",
|
||||
"update.file": "ダウンロードするファイル: {file}",
|
||||
"update.download": "ダウンロード",
|
||||
"update.release_page": "リリースページを開く",
|
||||
"update.skip": "このバージョンをスキップ",
|
||||
"update.later": "後で通知",
|
||||
"update.prefs_label": "アップデート",
|
||||
"update.check_automatically": "アップデートを自動的に確認",
|
||||
"update.check_now": "今すぐ確認",
|
||||
"update.checking": "確認中…",
|
||||
"update.failed": "確認できませんでした: {error}",
|
||||
"update.up_to_date": "SolidCraft は最新です。",
|
||||
"update.prefs_note": "これは SolidCraft {version} ({kind}) です。確認では GitHub に最新リリースを問い合わせるだけで、ほかには何も送信しません。",
|
||||
"update.prefs_dev": "これは開発ビルド ({version}) のため、アップデートを確認しません。",
|
||||
"update.show": "{version} へのアップデートを表示",
|
||||
"update.prefs_flatpak": "このコピー ({version}) は Flatpak が更新します: {command} を実行するか、ソフトウェアセンターを使ってください。SolidCraft 自身は確認しません。",
|
||||
"update.update_now": "今すぐ更新",
|
||||
"update.install_msi": "インストール (SolidCraft を終了)",
|
||||
"update.download_open": "ダウンロードして開く",
|
||||
"update.downloading": "ダウンロードと検証中… {done} / {total} MB",
|
||||
"update.installed": "SolidCraft {version} をインストールしました。再起動すると使えます。",
|
||||
"update.restart": "今すぐ再起動",
|
||||
"update.restart_later": "後で",
|
||||
"update.installer_started": "SolidCraft の終了後にインストーラーが起動します。",
|
||||
"update.dmg_opened": "ディスクイメージを検証して開きました: SolidCraft をアプリケーションフォルダにドラッグして古いものを置き換え、SolidCraft を再起動してください。",
|
||||
"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": "丸め",
|
||||
"joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。",
|
||||
"joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。",
|
||||
"joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。"
|
||||
}
|
||||
|
||||
+210
-1
@@ -1,10 +1,22 @@
|
||||
{
|
||||
"Send your report on GitHub": "GitHub에서 보고서 보내기",
|
||||
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 아래에서 GitHub 초안을 열고 클립보드에 복사된 보고서를 붙여넣으세요.",
|
||||
"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": "↖ 선택",
|
||||
@@ -37,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": "조립",
|
||||
@@ -67,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": "동심",
|
||||
@@ -260,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": "직사각형 패턴",
|
||||
@@ -271,9 +312,33 @@
|
||||
"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": "축척",
|
||||
"ui.orientation": "배향",
|
||||
"ui.twist": "비틀림",
|
||||
"ui.end_scale": "끝 배율",
|
||||
"ui.sweep_turn_with_path": "경로를 따라 회전",
|
||||
"ui.sweep_keep_as_drawn": "그린 대로 유지",
|
||||
"ui.sweep_follow_direction": "방향 따르기",
|
||||
"ui.section_analysis": "단면 분석",
|
||||
"ui.select": "선택",
|
||||
"ui.selection": "선택",
|
||||
@@ -361,6 +426,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": "반복",
|
||||
@@ -384,6 +460,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": "외관 목록",
|
||||
@@ -456,6 +535,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": "명령 목록",
|
||||
@@ -469,6 +551,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": "곡률빗 분석",
|
||||
@@ -495,6 +578,8 @@
|
||||
"cmd.material_delete": "물리 재질 삭제",
|
||||
"cmd.material_edit": "물리 재질 편집",
|
||||
"cmd.material_list": "재질 목록",
|
||||
"cmd.material_properties_directional": "방향별 탄성 계수",
|
||||
"cmd.material_properties_validate": "물리 재질 데이터 검증",
|
||||
"cmd.mesh_convert": "메쉬 변환",
|
||||
"cmd.model_edges": "모서리 목록",
|
||||
"cmd.model_faces": "면 목록",
|
||||
@@ -515,6 +600,7 @@
|
||||
"cmd.parameters_delete": "매개 변수 삭제",
|
||||
"cmd.parameters_export": "매개 변수 내보내기",
|
||||
"cmd.parameters_favorite": "즐겨찾는 매개 변수",
|
||||
"cmd.parameters_move": "매개 변수 이동",
|
||||
"cmd.parameters_graph": "매개 변수 그래프",
|
||||
"cmd.parameters_import": "매개 변수 가져오기",
|
||||
"cmd.parameters_list": "매개 변수 목록",
|
||||
@@ -540,6 +626,7 @@
|
||||
"cmd.sheet_fold": "접기",
|
||||
"cmd.sheet_hem": "헴",
|
||||
"cmd.sheet_manage_rules": "판금 규칙",
|
||||
"cmd.sheet_perforate": "타공",
|
||||
"cmd.sheet_refold": "다시 접기",
|
||||
"cmd.sheet_rules": "판금 규칙 목록",
|
||||
"cmd.sheet_unfold": "펼치기",
|
||||
@@ -637,9 +724,11 @@
|
||||
"cmd.surface_unstitch": "스티치 해제",
|
||||
"cmd.surface_reverse_normal": "법선 반전",
|
||||
"cmd.inspect_continuity": "연속성 분석",
|
||||
"cmd.timeline_add": "기록된 특성 추가",
|
||||
"cmd.timeline_compute_all": "모두 다시 계산",
|
||||
"cmd.timeline_copy": "특성 복사",
|
||||
"cmd.timeline_dependents": "특성 종속 항목",
|
||||
"cmd.timeline_export_script": "명령 스크립트 내보내기",
|
||||
"cmd.timeline_inspect": "특성 검사",
|
||||
"cmd.timeline_paste": "특성 붙여넣기",
|
||||
"cmd.timeline_redefine": "특성 편집",
|
||||
@@ -658,9 +747,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)": "바디 선택(면을 클릭하면 해당 바디 선택)",
|
||||
@@ -697,5 +789,122 @@
|
||||
"sketch_text.align_right": "오른쪽",
|
||||
"sketch_text.path_hint": "텍스트가 따라갈 스케치 곡선: 여기를 클릭한 다음 곡선을 클릭",
|
||||
"sketch_text.path_clear": "곡선에서 텍스트 떼기",
|
||||
"sketch_text.on_path": "텍스트가 경로를 따릅니다: 위치는 곡선이 정합니다"
|
||||
"sketch_text.on_path": "텍스트가 경로를 따릅니다: 위치는 곡선이 정합니다",
|
||||
"cmd.library_inspect": "로컬 라이브러리 확인",
|
||||
"cmd.library_export": "설계 라이브러리 내보내기",
|
||||
"cmd.library_apply": "로컬 라이브러리 항목 적용",
|
||||
"Thin": "얇은 벽",
|
||||
"Thin Extrude": "얇은 벽 돌출",
|
||||
"Wall Location": "벽 위치",
|
||||
"Wall Thickness": "벽 두께",
|
||||
"Thickness Outside": "바깥쪽 두께",
|
||||
"Inside": "안쪽",
|
||||
"Outside": "바깥쪽",
|
||||
"Profiles/Curves": "프로파일/곡선",
|
||||
"update.button": "업데이트",
|
||||
"update.button_tip": "SolidCraft {version} 사용 가능",
|
||||
"update.title": "업데이트 사용 가능",
|
||||
"update.available": "SolidCraft {version} 사용 가능",
|
||||
"update.you_have": "현재 버전은 {version}입니다.",
|
||||
"update.no_notes": "이 릴리스에는 릴리스 노트가 없습니다.",
|
||||
"update.system_hint": "이 사본은 {kind}입니다. 다음 명령으로 업데이트하세요:",
|
||||
"update.package_hint": "이 사본은 {kind}(으)로 설치되었습니다. 새 패키지를 내려받은 뒤 다음 명령으로 설치하세요:",
|
||||
"update.copy": "복사",
|
||||
"update.file": "내려받을 파일: {file}",
|
||||
"update.download": "다운로드",
|
||||
"update.release_page": "릴리스 페이지 열기",
|
||||
"update.skip": "이 버전 건너뛰기",
|
||||
"update.later": "나중에 알림",
|
||||
"update.prefs_label": "업데이트",
|
||||
"update.check_automatically": "업데이트 자동 확인",
|
||||
"update.check_now": "지금 확인",
|
||||
"update.checking": "확인 중…",
|
||||
"update.failed": "확인할 수 없습니다: {error}",
|
||||
"update.up_to_date": "SolidCraft가 최신 버전입니다.",
|
||||
"update.prefs_note": "SolidCraft {version} ({kind})입니다. 확인 시 GitHub에 최신 릴리스만 요청하며 다른 정보는 보내지 않습니다.",
|
||||
"update.prefs_dev": "개발 빌드({version})이므로 업데이트를 확인하지 않습니다.",
|
||||
"update.show": "{version} 업데이트 보기",
|
||||
"update.prefs_flatpak": "이 사본({version})은 Flatpak이 업데이트합니다: {command}를 실행하거나 소프트웨어 센터를 사용하세요. SolidCraft는 직접 확인하지 않습니다.",
|
||||
"update.update_now": "지금 업데이트",
|
||||
"update.install_msi": "설치 (SolidCraft 종료)",
|
||||
"update.download_open": "다운로드 후 열기",
|
||||
"update.downloading": "다운로드 및 확인 중… {done} / {total} MB",
|
||||
"update.installed": "SolidCraft {version}이(가) 설치되었습니다. 다시 시작하면 사용할 수 있습니다.",
|
||||
"update.restart": "지금 다시 시작",
|
||||
"update.restart_later": "나중에",
|
||||
"update.installer_started": "SolidCraft가 종료되면 설치 프로그램이 시작됩니다.",
|
||||
"update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: SolidCraft를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 SolidCraft를 다시 시작하세요.",
|
||||
"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": "유형",
|
||||
"joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.",
|
||||
"joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.",
|
||||
"joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다."
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+207
-1
@@ -1,4 +1,15 @@
|
||||
{
|
||||
"Send your report on GitHub": "在 GitHub 上发送报告",
|
||||
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 点击下方按钮打开 GitHub 草稿,然后粘贴已复制到剪贴板的报告。",
|
||||
"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})",
|
||||
@@ -77,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": "列出命令",
|
||||
@@ -90,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": "曲率梳分析",
|
||||
@@ -136,6 +151,7 @@
|
||||
"cmd.parameters_delete": "删除参数",
|
||||
"cmd.parameters_export": "导出参数",
|
||||
"cmd.parameters_favorite": "收藏参数",
|
||||
"cmd.parameters_move": "移动参数",
|
||||
"cmd.parameters_graph": "参数关系图",
|
||||
"cmd.parameters_import": "导入参数",
|
||||
"cmd.parameters_list": "列出参数",
|
||||
@@ -161,6 +177,7 @@
|
||||
"cmd.sheet_fold": "折叠",
|
||||
"cmd.sheet_hem": "卷边",
|
||||
"cmd.sheet_manage_rules": "钣金规则",
|
||||
"cmd.sheet_perforate": "冲孔",
|
||||
"cmd.sheet_refold": "重新折弯",
|
||||
"cmd.sheet_rules": "列出钣金规则",
|
||||
"cmd.sheet_unfold": "展开",
|
||||
@@ -259,9 +276,11 @@
|
||||
"cmd.surface_unstitch": "取消缝合",
|
||||
"cmd.surface_reverse_normal": "反转法向",
|
||||
"cmd.inspect_continuity": "连续性分析",
|
||||
"cmd.timeline_add": "添加记录的特征",
|
||||
"cmd.timeline_compute_all": "全部重新計算",
|
||||
"cmd.timeline_copy": "复制特征",
|
||||
"cmd.timeline_dependents": "特征依存项",
|
||||
"cmd.timeline_export_script": "导出命令脚本",
|
||||
"cmd.timeline_inspect": "检查特征",
|
||||
"cmd.timeline_paste": "粘贴特征",
|
||||
"cmd.timeline_redefine": "编辑特征",
|
||||
@@ -275,6 +294,7 @@
|
||||
"cmd.view_list": "列出命名视图",
|
||||
"cmd.view_rename": "重命名命名视图",
|
||||
"cmd.view_save": "新建命名视图",
|
||||
"dialog.computing": "正在计算… {seconds} 秒",
|
||||
"dialog.custom_axis": "↖ 自定义轴",
|
||||
"dialog.edit_title": "编辑 {title}",
|
||||
"dialog.select": "↖ 选择",
|
||||
@@ -308,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": "装配",
|
||||
@@ -341,6 +371,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": "同心",
|
||||
@@ -551,6 +596,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": "矩形阵列",
|
||||
@@ -565,9 +614,33 @@
|
||||
"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": "缩放",
|
||||
"ui.orientation": "定向",
|
||||
"ui.twist": "扭转",
|
||||
"ui.end_scale": "末端缩放",
|
||||
"ui.sweep_turn_with_path": "随路径转动",
|
||||
"ui.sweep_keep_as_drawn": "保持绘制方向",
|
||||
"ui.sweep_follow_direction": "跟随方向",
|
||||
"ui.section_analysis": "截面分析",
|
||||
"ui.select": "选择",
|
||||
"ui.select_other_hint": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。",
|
||||
@@ -719,6 +792,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": "字体",
|
||||
@@ -907,6 +982,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": "重复",
|
||||
@@ -930,6 +1016,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 +1046,122 @@
|
||||
"sketch_text.on_path": "文字沿路径排列:由曲线放置",
|
||||
"ui.analysis": "分析",
|
||||
"ui.edit_section_analysis": "编辑截面分析",
|
||||
"cmd.material_properties_validate":"验证物理材料数据"
|
||||
"cmd.library_inspect": "检查本地库",
|
||||
"cmd.library_export": "导出设计库",
|
||||
"cmd.library_apply": "应用本地库条目",
|
||||
"Thin": "薄壁",
|
||||
"Thin Extrude": "薄壁拉伸",
|
||||
"Wall Location": "壁位置",
|
||||
"Wall Thickness": "壁厚度",
|
||||
"Thickness Outside": "外侧厚度",
|
||||
"Inside": "内侧",
|
||||
"Outside": "外侧",
|
||||
"Profiles/Curves": "轮廓/曲线",
|
||||
"cmd.material_properties_validate": "验证物理材料数据",
|
||||
"cmd.material_properties_directional": "方向弹性模量",
|
||||
"update.button": "更新",
|
||||
"update.button_tip": "SolidCraft {version} 已发布",
|
||||
"update.title": "有可用更新",
|
||||
"update.available": "SolidCraft {version} 已发布",
|
||||
"update.you_have": "当前版本为 {version}。",
|
||||
"update.no_notes": "此版本没有发布说明。",
|
||||
"update.system_hint": "此副本为 {kind}。用以下命令更新:",
|
||||
"update.package_hint": "此副本来自 {kind}。下载新软件包后用以下命令安装:",
|
||||
"update.copy": "复制",
|
||||
"update.file": "将下载 {file}。",
|
||||
"update.download": "下载",
|
||||
"update.release_page": "打开发布页面",
|
||||
"update.skip": "跳过此版本",
|
||||
"update.later": "稍后提醒",
|
||||
"update.prefs_label": "更新",
|
||||
"update.check_automatically": "自动检查更新",
|
||||
"update.check_now": "立即检查",
|
||||
"update.checking": "正在检查…",
|
||||
"update.failed": "无法检查: {error}",
|
||||
"update.up_to_date": "SolidCraft 已是最新版本。",
|
||||
"update.prefs_note": "当前为 SolidCraft {version}({kind})。检查时只向 GitHub 查询最新版本,不发送其他任何数据。",
|
||||
"update.prefs_dev": "这是开发版本({version}),不检查更新。",
|
||||
"update.show": "显示 {version} 更新",
|
||||
"update.prefs_flatpak": "此副本({version})由 Flatpak 更新:运行 {command} 或使用软件中心。SolidCraft 不会自行检查。",
|
||||
"update.update_now": "立即更新",
|
||||
"update.install_msi": "安装(将关闭 SolidCraft)",
|
||||
"update.download_open": "下载并打开",
|
||||
"update.downloading": "正在下载并校验… {done} / {total} MB",
|
||||
"update.installed": "SolidCraft {version} 已安装。重新启动后生效。",
|
||||
"update.restart": "立即重启",
|
||||
"update.restart_later": "稍后",
|
||||
"update.installer_started": "SolidCraft 关闭后将启动安装程序。",
|
||||
"update.dmg_opened": "磁盘映像已校验并打开:将 SolidCraft 拖到“应用程序”替换旧版本,然后重启 SolidCraft。",
|
||||
"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": "圆角",
|
||||
"joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。",
|
||||
"joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。",
|
||||
"joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -75,12 +75,55 @@ impl Tokens {
|
||||
overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235),
|
||||
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),
|
||||
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 {
|
||||
@@ -133,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 {
|
||||
@@ -162,12 +196,12 @@ impl Document {
|
||||
pub fn face_colors(&self, b: &crate::ModelBody) -> Vec<(usize, Look)> {
|
||||
// Face colours the body was imported with, under the design's own.
|
||||
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let names = crate::naming::face_names(b);
|
||||
let mut out: Vec<(usize, Look)> = match b.body.paint() {
|
||||
Some(p) => p.faces.iter().map(|f| (f.face, Look::custom(f.color.map(to8), f.opacity as f64))).collect(),
|
||||
// Carried from an earlier version of the body: by face name.
|
||||
// Carried from an earlier version of the body: by face name (worked out only here:
|
||||
// the viewport asks every body for its face colours on every change).
|
||||
None => match &b.imported {
|
||||
Some(imp) => names
|
||||
Some(imp) => crate::naming::face_names(b)
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, n)| imp.faces.get(crate::naming::strip_piece(n)).map(|(c, o)| (i, Look::custom(c.map(to8), *o as f64))))
|
||||
|
||||
+13
-153
@@ -11,7 +11,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::{Plane, Vec3};
|
||||
|
||||
use crate::{AxisRef, DocError, Document, FeatureKind, PatternKind, PlaneRef, Result};
|
||||
use crate::{DocError, Document, FeatureKind, PlaneRef, Result};
|
||||
|
||||
/// Column-major 4×4 affine transform (`m[col][row]`).
|
||||
pub type Mat = [[f64; 4]; 4];
|
||||
@@ -204,157 +204,17 @@ pub fn map_point_expr(m: &Mat, p: &mut crate::PointExpr) {
|
||||
impl FeatureKind {
|
||||
/// Map the feature's world geometry (points, directions, explicit planes) by `m`.
|
||||
pub fn transform_geometry(&mut self, m: &Mat) {
|
||||
let pt = |p: &mut Vec3| *p = apply_point(m, *p);
|
||||
let dir = |v: &mut Vec3| *v = apply_vector(m, *v);
|
||||
match self {
|
||||
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Mirror { plane, .. } => {
|
||||
map_plane_ref(m, plane)
|
||||
}
|
||||
FeatureKind::ConstructionAxis { def } => def.refs_mut().into_iter().for_each(|r| map_geo_ref(m, r)),
|
||||
FeatureKind::ConstructionPoint { def } => def.refs_mut().into_iter().for_each(|r| map_geo_ref(m, r)),
|
||||
FeatureKind::Split { plane, tool, .. } => {
|
||||
map_plane_ref(m, plane);
|
||||
if let Some(t) = tool {
|
||||
pt(&mut t.point);
|
||||
}
|
||||
}
|
||||
FeatureKind::SplitFace { faces, plane, tool, .. } => {
|
||||
faces.iter_mut().for_each(pt);
|
||||
map_plane_ref(m, plane);
|
||||
if let Some(t) = tool {
|
||||
pt(&mut t.point);
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { axis: AxisRef::Line { origin, dir: d }, .. }
|
||||
| FeatureKind::SurfaceRevolve { axis: AxisRef::Line { origin, dir: d }, .. } => {
|
||||
pt(origin);
|
||||
dir(d);
|
||||
}
|
||||
FeatureKind::Fillet { edges, style, .. } => {
|
||||
edges.iter_mut().for_each(pt);
|
||||
if let crate::FilletStyle::Variable { start, .. } | crate::FilletStyle::Spline { start, .. } = style {
|
||||
pt(start);
|
||||
}
|
||||
}
|
||||
FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::Shell { faces, .. }
|
||||
| FeatureKind::OffsetFace { faces, .. }
|
||||
| FeatureKind::DeleteFace { faces, .. }
|
||||
| FeatureKind::SurfaceOffset { faces, .. } => faces.iter_mut().for_each(pt),
|
||||
FeatureKind::Draft { faces, neutral, pull, .. } => {
|
||||
faces.iter_mut().for_each(pt);
|
||||
map_plane_ref(m, neutral);
|
||||
dir(pull);
|
||||
}
|
||||
FeatureKind::Box { corner, .. } => map_point_expr(m, corner),
|
||||
FeatureKind::Cylinder { base, axis, .. } => {
|
||||
map_point_expr(m, base);
|
||||
dir(axis);
|
||||
}
|
||||
FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => map_point_expr(m, center),
|
||||
FeatureKind::Pattern { pattern: PatternKind::Rectangular { dir1, dir2, .. }, .. } => {
|
||||
dir(dir1);
|
||||
if let Some(d) = dir2 {
|
||||
dir(d);
|
||||
}
|
||||
}
|
||||
FeatureKind::Pattern { pattern: PatternKind::Circular { origin, axis, .. }, .. } => {
|
||||
pt(origin);
|
||||
dir(axis);
|
||||
}
|
||||
// Follows its sketch.
|
||||
FeatureKind::Pattern { pattern: PatternKind::Path { .. }, .. } => {}
|
||||
FeatureKind::Hole { position, direction, .. } => {
|
||||
pt(position);
|
||||
dir(direction);
|
||||
}
|
||||
FeatureKind::Thread { face, .. } => pt(face),
|
||||
FeatureKind::SheetFlange { edges, .. } => edges.iter_mut().for_each(|e| pt(&mut e.point)),
|
||||
FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SheetConvert { face, bends, .. } => {
|
||||
pt(face);
|
||||
bends.iter_mut().for_each(pt);
|
||||
}
|
||||
FeatureKind::SheetFold { a, b, fixed, .. } => {
|
||||
pt(a);
|
||||
pt(b);
|
||||
if let Some(f) = fixed {
|
||||
pt(f);
|
||||
}
|
||||
}
|
||||
FeatureKind::Boss { position, direction, .. } => {
|
||||
pt(position);
|
||||
dir(direction);
|
||||
}
|
||||
FeatureKind::Lip { face, .. } => pt(face),
|
||||
FeatureKind::BoundaryFill { cells, .. } => cells.iter_mut().for_each(pt),
|
||||
FeatureKind::Rest { position, direction, along, .. } => {
|
||||
pt(position);
|
||||
dir(direction);
|
||||
if let Some(a) = along {
|
||||
dir(a);
|
||||
}
|
||||
}
|
||||
FeatureKind::SnapFit { position, direction, hook, .. } => {
|
||||
pt(position);
|
||||
dir(direction);
|
||||
dir(hook);
|
||||
}
|
||||
FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {}
|
||||
FeatureKind::Coil { base, axis, .. } => {
|
||||
map_point_expr(m, base);
|
||||
dir(axis);
|
||||
}
|
||||
FeatureKind::ReplaceFace { faces, target, .. } => {
|
||||
faces.iter_mut().for_each(pt);
|
||||
map_plane_ref(m, target);
|
||||
}
|
||||
FeatureKind::Align { from, to, from_normal, to_normal, .. } => {
|
||||
pt(from);
|
||||
pt(to);
|
||||
if let Some(n) = from_normal {
|
||||
dir(n);
|
||||
}
|
||||
if let Some(n) = to_normal {
|
||||
dir(n);
|
||||
}
|
||||
}
|
||||
FeatureKind::Scale { origin, .. } => pt(origin),
|
||||
FeatureKind::Move { rotate_axis, .. } => {
|
||||
if let Some(a) = rotate_axis {
|
||||
dir(a);
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { .. }
|
||||
| FeatureKind::Extrude { .. }
|
||||
| FeatureKind::SurfaceExtrude { .. }
|
||||
| FeatureKind::SurfaceRevolve { .. }
|
||||
| FeatureKind::SurfaceSweep { .. }
|
||||
| FeatureKind::SurfaceLoft { .. }
|
||||
| FeatureKind::Combine { .. }
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
| FeatureKind::BoundingSolid { .. }
|
||||
| FeatureKind::Pipe { .. }
|
||||
| FeatureKind::Emboss { .. }
|
||||
| FeatureKind::Rib { .. }
|
||||
| FeatureKind::Remove { .. }
|
||||
| FeatureKind::Stitch { .. }
|
||||
| FeatureKind::Thicken { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. }
|
||||
| FeatureKind::MeshConvert { .. }
|
||||
| FeatureKind::BodyTransfer { .. } => {}
|
||||
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::Unstitch { faces, .. } | FeatureKind::ReverseNormal { faces, .. } => faces.iter_mut().for_each(pt),
|
||||
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
|
||||
map_plane_ref(m, plane);
|
||||
if let Some(t) = tool {
|
||||
pt(&mut t.point);
|
||||
}
|
||||
pt(keep);
|
||||
}
|
||||
}
|
||||
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),
|
||||
Geo::Plane(p) => map_plane_ref(m, p),
|
||||
Geo::Construct(r) => map_geo_ref(m, r),
|
||||
Geo::Placement(e) => map_point_expr(m, e),
|
||||
// An Emboss cylinder is picked in its component's frame (`EmbossFace`).
|
||||
Geo::Pinned(_) | Geo::Profiles(..) => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,7 +367,7 @@ impl Document {
|
||||
return Err(DocError::Invalid("too many occurrences".into()));
|
||||
}
|
||||
let id = self.next_id.max(1);
|
||||
self.next_id = id + 1;
|
||||
self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
|
||||
let cname = self.components.iter().find(|c| c.id == component).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let k = self.occurrences.iter().filter(|o| o.component == component).count() + 1;
|
||||
self.occurrences.push(crate::Occurrence { id, component, parent, name: format!("{cname}:{k}"), transform, grounded: false });
|
||||
|
||||
+3
-163
@@ -3,83 +3,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{FeatureKind, PatternKind, PlaneRef};
|
||||
|
||||
fn plane_names(p: &mut PlaneRef, names: &BTreeMap<String, String>) {
|
||||
let mut p = p;
|
||||
for _ in 0..64 {
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
|
||||
PlaneRef::Construction { name } => {
|
||||
if let Some(n) = names.get(name) {
|
||||
*name = n.clone();
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
/// Point references to sketches (by id) and features (by name) at their copies.
|
||||
pub fn remap_refs(&mut self, sketches: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>) {
|
||||
let sk = |id: &mut u64| {
|
||||
if let Some(n) = sketches.get(id) {
|
||||
*id = *n;
|
||||
}
|
||||
};
|
||||
let feats = |list: &mut Vec<String>| {
|
||||
for x in list.iter_mut() {
|
||||
if let Some(n) = names.get(x) {
|
||||
*x = n.clone();
|
||||
}
|
||||
}
|
||||
};
|
||||
match self {
|
||||
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } => {
|
||||
plane_names(plane, names)
|
||||
}
|
||||
FeatureKind::SplitFace { plane, sketch, .. } => {
|
||||
plane_names(plane, names);
|
||||
if let Some(s) = sketch {
|
||||
sk(s);
|
||||
}
|
||||
}
|
||||
FeatureKind::Extrude { sketch, .. }
|
||||
| FeatureKind::SurfaceExtrude { sketch, .. }
|
||||
| FeatureKind::SurfaceRevolve { sketch, .. }
|
||||
| FeatureKind::Revolve { sketch, .. }
|
||||
| FeatureKind::Emboss { sketch, .. }
|
||||
| FeatureKind::SheetBase { sketch, .. }
|
||||
| FeatureKind::SheetContour { sketch, .. }
|
||||
| FeatureKind::Rib { sketch, .. } => sk(sketch),
|
||||
FeatureKind::Patch { sketch: Some(s), .. } => sk(s),
|
||||
FeatureKind::Sweep { sketch, path_sketch, .. } | FeatureKind::SurfaceSweep { sketch, path_sketch, .. } => {
|
||||
sk(sketch);
|
||||
sk(path_sketch);
|
||||
}
|
||||
FeatureKind::Pipe { path_sketch, .. } => sk(path_sketch),
|
||||
FeatureKind::SheetFold { sketch: Some(s), .. } => sk(s),
|
||||
FeatureKind::Loft { sections, .. } => sections.iter_mut().for_each(|s| sk(&mut s.sketch)),
|
||||
FeatureKind::SurfaceLoft { sections } => sections.iter_mut().for_each(|s| sk(&mut s.sketch)),
|
||||
FeatureKind::Hole { points: Some(p), .. } => sk(&mut p.sketch),
|
||||
FeatureKind::Pattern { features, pattern, .. } => {
|
||||
feats(features);
|
||||
if let PatternKind::Path { path_sketch, .. } = pattern {
|
||||
sk(path_sketch);
|
||||
}
|
||||
}
|
||||
FeatureKind::Mirror { features, plane, .. } => {
|
||||
feats(features);
|
||||
plane_names(plane, names);
|
||||
}
|
||||
FeatureKind::Draft { neutral, .. } => plane_names(neutral, names),
|
||||
FeatureKind::ReplaceFace { target, .. } => plane_names(target, names),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::{FeatureKind, PlaneRef};
|
||||
|
||||
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
|
||||
pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>) -> String {
|
||||
@@ -246,92 +170,6 @@ fn remap_link(
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
/// Point the body names a feature acts on (targets, tools, the body it rounds…) at the
|
||||
/// copies in `bodies`; names not in the map stay as they are.
|
||||
pub fn remap_bodies(&mut self, bodies: &BTreeMap<String, String>) {
|
||||
use FeatureKind as K;
|
||||
match self {
|
||||
K::Emboss { face: Some(face), .. } => mapped_name(&mut face.body, bodies),
|
||||
K::Split { tool: Some(tool), .. } | K::SplitFace { tool: Some(tool), .. } | K::SurfaceTrim { tool: Some(tool), .. } => {
|
||||
mapped_name(&mut tool.body, bodies)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match self {
|
||||
K::Extrude { targets, .. }
|
||||
| K::Revolve { targets, .. }
|
||||
| K::Loft { targets, .. }
|
||||
| K::Sweep { targets, .. }
|
||||
| K::Pipe { targets, .. }
|
||||
| K::Emboss { targets, .. } => {
|
||||
for n in targets {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Combine { target, tools, .. } => {
|
||||
mapped_name(target, bodies);
|
||||
for n in tools {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Pattern { bodies: list, .. }
|
||||
| K::Mirror { bodies: list, .. }
|
||||
| K::Scale { bodies: list, .. }
|
||||
| K::BoundingSolid { bodies: list, .. }
|
||||
| K::Align { bodies: list, .. }
|
||||
| K::Remove { bodies: list }
|
||||
| K::Stitch { bodies: list, .. }
|
||||
| K::Move { bodies: list, .. }
|
||||
| K::BodyTransfer { bodies: list, .. } => {
|
||||
for n in list {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Thicken { bodies: list, targets, .. } => {
|
||||
for n in list.iter_mut().chain(targets) {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::BoundaryFill { tools, .. } => {
|
||||
for n in tools {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Fillet { body, .. }
|
||||
| K::Chamfer { body, .. }
|
||||
| K::Shell { body, .. }
|
||||
| K::Draft { body, .. }
|
||||
| K::SplitFace { body, .. }
|
||||
| K::OffsetFace { body, .. }
|
||||
| K::DeleteFace { body, .. }
|
||||
| K::SurfaceOffset { body, .. }
|
||||
| K::ReplaceFace { body, .. }
|
||||
| K::SheetFlange { body, .. }
|
||||
| K::SheetHem { body, .. }
|
||||
| K::SheetFold { body, .. }
|
||||
| K::SheetUnfold { body, .. }
|
||||
| K::Boss { body, .. }
|
||||
| K::Lip { body, .. }
|
||||
| K::SnapFit { body, .. }
|
||||
| K::Rest { body, .. }
|
||||
| K::Patch { body, .. } => {
|
||||
if let Some(n) = body {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Split { body, .. }
|
||||
| K::SheetConvert { body, .. }
|
||||
| K::SurfaceExtend { body, .. }
|
||||
| K::SurfaceTrim { body, .. }
|
||||
| K::Unstitch { body, .. }
|
||||
| K::ReverseNormal { body, .. }
|
||||
| K::MeshConvert { body, .. } => mapped_name(body, bodies),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Feature {
|
||||
/// Clone identities through typed reference fields. Sketch-local entity IDs stay local.
|
||||
pub fn remap_clone(
|
||||
@@ -356,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)?;
|
||||
@@ -410,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)?;
|
||||
|
||||
@@ -118,6 +118,7 @@ fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"2"})).unwrap(),
|
||||
@@ -280,6 +281,7 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
@@ -429,6 +431,7 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
@@ -553,6 +556,7 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
|
||||
let material = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
|
||||
@@ -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));
|
||||
@@ -575,15 +580,6 @@ impl GeoRef {
|
||||
}
|
||||
}
|
||||
|
||||
impl GeoRef {
|
||||
fn plane_ref(&self) -> Option<&PlaneRef> {
|
||||
match self {
|
||||
GeoRef::Plane { plane } => Some(plane),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AxisDef {
|
||||
pub fn refs(&self) -> Vec<&GeoRef> {
|
||||
match self {
|
||||
@@ -594,11 +590,6 @@ impl AxisDef {
|
||||
}
|
||||
}
|
||||
|
||||
/// Planes it refers to (whose expressions it depends on).
|
||||
pub fn planes(&self) -> Vec<&PlaneRef> {
|
||||
self.refs().into_iter().filter_map(GeoRef::plane_ref).collect()
|
||||
}
|
||||
|
||||
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
|
||||
match self {
|
||||
AxisDef::ThroughFace { face } => vec![face],
|
||||
@@ -621,10 +612,6 @@ impl PointDef {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn planes(&self) -> Vec<&PlaneRef> {
|
||||
self.refs().into_iter().filter_map(GeoRef::plane_ref).collect()
|
||||
}
|
||||
|
||||
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
|
||||
match self {
|
||||
PointDef::At { point } => vec![point],
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
+645
-355
File diff suppressed because it is too large
Load Diff
+294
-37
@@ -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.
|
||||
@@ -276,6 +292,24 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
|
||||
if len / p > 190.0 {
|
||||
return Err(DocError::Invalid(format!("a modelled thread is limited to 190 turns ({:.0} mm at this pitch); use a cosmetic one", 190.0 * p)));
|
||||
}
|
||||
// How deep the groove cuts into the material: from the hole wall out to the major diameter,
|
||||
// or from the shaft's surface in to the root. Next to nothing (a hole as wide as the thread,
|
||||
// #995) is refused: the faceted cut would leave the face faceted and unthreaded.
|
||||
let basic = 5.0 * h / 8.0;
|
||||
let depth = if t.internal { d / 2.0 - radius } else { radius - (d / 2.0 - basic) };
|
||||
if !(depth >= basic / 4.0) {
|
||||
let (m, dia) = (&t.designation, 2.0 * radius);
|
||||
let cut = format!("it would cut {:.2} mm of the thread's {basic:.2} mm depth", depth.max(0.0));
|
||||
let alt = thread_for(dia, t.internal).map(|(n, q)| format_thread(n, q)).filter(|a| a != m);
|
||||
let shown = show_mm(dia);
|
||||
return Err(DocError::Invalid(if t.internal {
|
||||
let alt = alt.map(|a| format!(", or thread this hole {a}")).unwrap_or_default();
|
||||
format!("this {shown} mm hole is too wide for a modelled {m} thread: {cut}; drill {} mm for {m}{alt}", show_mm(d - p))
|
||||
} else {
|
||||
let alt = alt.map(|a| format!(", or thread this shaft {a}")).unwrap_or_default();
|
||||
format!("this {shown} mm shaft is too thin for a modelled {m} thread: {cut}; make the shaft {} mm for {m}{alt}", show_mm(d))
|
||||
}));
|
||||
}
|
||||
// (radius, half width of the groove) at its two ends: a trapezoid in the axial plane.
|
||||
let (r0, w0, r1, w1) = if t.internal {
|
||||
// From inside the hole wall out to the major diameter, where the bolt's crest sits
|
||||
@@ -1201,6 +1235,8 @@ impl Model {
|
||||
}
|
||||
}
|
||||
state = Arc::new(next);
|
||||
// A window opening the design can show what is built so far.
|
||||
watched.offer(i + 1, doc, &state);
|
||||
None
|
||||
}
|
||||
Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()),
|
||||
@@ -1261,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() {
|
||||
@@ -1771,6 +1818,26 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi:
|
||||
(lo2, hi2)
|
||||
}
|
||||
|
||||
/// How high (mm) a tapered tool's sides stand straight where it starts on a face it can't run
|
||||
/// on into.
|
||||
const TAPER_FOOT: f64 = 0.01;
|
||||
|
||||
/// Whether a face of the targets (all bodies when none are named) lies in the plane at height
|
||||
/// `h` along `plane`'s normal.
|
||||
fn face_level_at(st: &ModelState, plane: &Plane, h: f64, targets: &[String]) -> bool {
|
||||
let n = plane.normal();
|
||||
st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).any(|b| {
|
||||
let m = b.mesh();
|
||||
let tol = (b.body.size() * 1e-7).max(1e-9);
|
||||
m.triangles.iter().any(|t| {
|
||||
m.tri(t).is_some_and(|[a, b, c]| {
|
||||
(b - a).cross(c - a).normalized().is_some_and(|normal| normal.dot(n).abs() > 1.0 - 1e-9)
|
||||
&& ((a - plane.origin).dot(n) - h).abs() < tol
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The body a face-based feature applies to: by name, or the one whose surface is nearest the
|
||||
/// first point.
|
||||
fn body_at(st: &ModelState, body: &Option<String>, points: &[Vec3]) -> Result<usize> {
|
||||
@@ -1932,6 +1999,24 @@ pub fn edge_names_for(st: &ModelState, body: &Option<String>, pts: &[Vec3]) -> V
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored pick sits on the axis of a round, not on an edge. The nearest edge within a
|
||||
/// quarter of the body is that round's tangency seam, which is a different edge (#775).
|
||||
fn pick_lies_on_a_round_axis(body: &Body, p: Vec3) -> bool {
|
||||
let Some(cyl) = kernel::cylinder_face_at(body, p) else { return false };
|
||||
let Some(axis) = cyl.axis.normalized() else { return false };
|
||||
let radial = p - cyl.axis_point;
|
||||
let dist = (radial - axis * radial.dot(axis)).len();
|
||||
if !(cyl.radius > 1e-6 && dist < cyl.radius * 0.35) {
|
||||
return false;
|
||||
}
|
||||
let tight = (body.size() * 2e-3).max(1e-3);
|
||||
// A pick on the wall itself is the seam. Only an axial pick is the vanished corner.
|
||||
match body.nearest_edge(p, cyl.radius * 2.0) {
|
||||
Ok(Some((_, d))) => d > tight,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge reference points re-found on the body: points on an edge stay; a point that moved off
|
||||
/// (an upstream edit) is carried with the body (its place in the body's bounding box then and
|
||||
/// now) to an edge running the same way; failing that it goes to the nearest edge with a
|
||||
@@ -2191,17 +2276,24 @@ fn hole_feature_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Featu
|
||||
/// counterbore) is machined like the equivalent extrude cut, so the sheet keeps it as a cut-out
|
||||
/// or pocket of its flat pattern (#560). Returns the sheet bodies it machined; none, with the
|
||||
/// model as it was, when a tool that meets a sheet can't be kept that way (the ordinary cut then
|
||||
/// refuses it).
|
||||
fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<more::PrimitivePrism>)]) -> Result<Vec<String>> {
|
||||
/// refuses it). Pieces the hole parts from a sheet's base are named in `warning`.
|
||||
fn sheet_hole_cut(
|
||||
st: &mut ModelState,
|
||||
f: &Feature,
|
||||
tools: &[(Traced, Option<more::PrimitivePrism>)],
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
if st.sheets.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let saved = (st.sheets.clone(), st.bodies.clone());
|
||||
let mut handled: Vec<String> = Vec::new();
|
||||
// Noted only when every tool was machined here.
|
||||
let mut parted = None;
|
||||
for (tool, prism) in tools {
|
||||
let tool = &tool.body;
|
||||
let done = match prism {
|
||||
Some(prism) => match more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut, true) {
|
||||
Some(prism) => match more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut, true, &mut parted) {
|
||||
Ok(done) => done,
|
||||
// Drilled along a panel (into a rim) the hole is no flat cut-out: a bore of
|
||||
// that panel, when it stays inside it.
|
||||
@@ -2237,6 +2329,9 @@ fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<mor
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(note) = parted {
|
||||
append_warning(warning, note);
|
||||
}
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
@@ -2495,6 +2590,35 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The regions a thin extrude's wall covers in its sketch.
|
||||
pub(crate) fn thin_regions(vals: &BTreeMap<String, Value>, ss: &SolvedSketch, thin: &crate::ThinWall) -> Result<Vec<Region2>> {
|
||||
if thin.curves.is_empty() || thin.curves.len() > 1000 {
|
||||
return Err(DocError::Invalid("a thin extrude needs 1…1000 sketch curves".into()));
|
||||
}
|
||||
let t = val(vals, &thin.thickness, Kind::Length)?;
|
||||
let (inside, outside) = match thin.side {
|
||||
crate::ThinSide::Center => (t / 2.0, t / 2.0),
|
||||
crate::ThinSide::Inside => (t, 0.0),
|
||||
crate::ThinSide::Outside => (0.0, t),
|
||||
crate::ThinSide::TwoSides => {
|
||||
let e = thin.thickness2.as_deref().ok_or_else(|| DocError::Invalid("a two-sided thin wall needs `thickness2`".into()))?;
|
||||
(t, val(vals, e, Kind::Length)?)
|
||||
}
|
||||
};
|
||||
if !(inside >= 0.0 && outside >= 0.0 && inside + outside > 0.0) {
|
||||
return Err(DocError::Invalid("the wall thickness must be positive".into()));
|
||||
}
|
||||
let curves = thin
|
||||
.curves
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}` of {}", ss.name)))?;
|
||||
Ok(ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
solidcraft_sketch::thin_wall_regions(&curves, inside, outside).map_err(|e| DocError::Invalid(format!("thin wall: {e}")))
|
||||
}
|
||||
|
||||
/// How far apart (mm) profiles must stay for their extruded tools to be cut as one.
|
||||
const APART_GAP: f64 = 1e-3;
|
||||
|
||||
@@ -2508,13 +2632,16 @@ fn feature_tools_with_constructor(
|
||||
) -> Result<Vec<Traced>> {
|
||||
let unnamed = |bodies: Vec<Body>| -> Vec<Traced> { bodies.into_iter().map(Traced::unnamed).collect() };
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets, thin } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let regions = match thin {
|
||||
Some(thin) => thin_regions(vals, &ss, thin)?,
|
||||
None => solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?),
|
||||
};
|
||||
// From / To Object: where the face or point lies along the normal, measured over the
|
||||
// middle of the profiles. The points are in this feature's component frame, so the
|
||||
// model is seen from there: a target in another, moved component where it is shown.
|
||||
@@ -2564,13 +2691,48 @@ fn feature_tools_with_constructor(
|
||||
Some(crate::lineage::extrude_cap_role(from)),
|
||||
Some(crate::lineage::extrude_cap_role(if sign < 0.0 { from - len } else { from + len })),
|
||||
];
|
||||
return regions
|
||||
// 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| {
|
||||
let body = kernel::extrude_tapered(&start_plane, r, len, sign, taper)?;
|
||||
// An end on a face of the target runs on into it, as a prism's does: tapered
|
||||
// sides standing on a face are curved where they meet it (along text, say),
|
||||
// and the boolean finds no result there. The sides keep their slope, so
|
||||
// the profile stays the section at the start.
|
||||
let (lo, hi) = if sign < 0.0 { (from - len, from) } else { (from, from + len) };
|
||||
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo, hi, *operation, targets);
|
||||
let (a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) };
|
||||
// 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 && 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))
|
||||
})
|
||||
.collect();
|
||||
.collect::<Result<_>>()?;
|
||||
// Profiles apart by more than the tools grow (the letters of a text): the tools
|
||||
// are apart too, and go into the body as one, in one boolean rather than one per
|
||||
// profile, each on a body grown or cut by the ones before. (A join meeting several
|
||||
// bodies keeps joining them tool by tool; sheet metal keeps cutting tool by tool.)
|
||||
let sheet =
|
||||
if targets.is_empty() { !st.sheets.is_empty() } else { targets.iter().any(|t| st.sheets.iter().any(|sheet| &sheet.body == t)) };
|
||||
let one_target = if targets.is_empty() { st.bodies.len() == 1 } else { targets.len() == 1 };
|
||||
let at_once = match operation {
|
||||
Operation::Cut => !sheet,
|
||||
Operation::Join => one_target && !sheet,
|
||||
_ => false,
|
||||
};
|
||||
if at_once && tools.len() > 1 && regions_apart(®ions, APART_GAP + 2.0 * growth) {
|
||||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||||
if let Ok(Some(one)) = kernel::join_apart(&bodies) {
|
||||
return Ok(vec![Traced::step(one, &tools.iter().collect::<Vec<_>>())]);
|
||||
}
|
||||
}
|
||||
return Ok(tools);
|
||||
}
|
||||
let off = from
|
||||
+ match &extent.start_offset {
|
||||
@@ -2825,6 +2987,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)))?;
|
||||
@@ -2880,6 +3043,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);
|
||||
}
|
||||
@@ -2889,7 +3053,8 @@ fn feature_tools_with_constructor(
|
||||
let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() };
|
||||
Ok(vec![roles.tool(body)])
|
||||
}
|
||||
FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => {
|
||||
FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, .. } => {
|
||||
let ctl = sweep_control(vals, orientation, twist, scale)?;
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
|
||||
let ps =
|
||||
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
|
||||
@@ -2907,10 +3072,10 @@ fn feature_tools_with_constructor(
|
||||
let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None);
|
||||
// A path through 3D sketch curves follows them as points.
|
||||
if let Some(pts) = path_points(ps, path, start)? {
|
||||
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path(&ss.plane, r, &pts)?))).collect();
|
||||
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path_with(&ss.plane, r, &pts, &ctl)?))).collect();
|
||||
}
|
||||
let segs = path_segments(ps, path, start)?;
|
||||
regions.iter().map(|r| Ok(tool(r, kernel::sweep(&ss.plane, r, &segs)?))).collect()
|
||||
regions.iter().map(|r| Ok(tool(r, kernel::sweep_with(&ss.plane, r, &segs, &ctl)?))).collect()
|
||||
}
|
||||
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
|
||||
let ps =
|
||||
@@ -2958,6 +3123,32 @@ fn feature_tools_with_constructor(
|
||||
}
|
||||
|
||||
/// A chain of sketch curves as 3D path segments, starting at the end nearest `start`.
|
||||
/// A sweep's options from its stored orientation and its twist and end scale expressions
|
||||
/// (none: no twist, scale 1). Out-of-range values are refused by the kernel.
|
||||
pub(crate) fn sweep_control(
|
||||
vals: &BTreeMap<String, Value>,
|
||||
orientation: &crate::SweepOrientation,
|
||||
twist: &Option<String>,
|
||||
scale: &Option<String>,
|
||||
) -> Result<kernel::SweepControl> {
|
||||
let orientation = match *orientation {
|
||||
crate::SweepOrientation::Perpendicular => kernel::SweepOrientation::Perpendicular,
|
||||
crate::SweepOrientation::Parallel => kernel::SweepOrientation::Parallel,
|
||||
crate::SweepOrientation::Direction(d) => kernel::SweepOrientation::Direction(d),
|
||||
};
|
||||
let twist = match twist {
|
||||
Some(t) => val(vals, t, Kind::Angle)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let scale = match scale {
|
||||
Some(k) => val(vals, k, Kind::Unitless)?,
|
||||
None => 1.0,
|
||||
};
|
||||
let ctl = kernel::SweepControl { orientation, twist, scale };
|
||||
ctl.check()?;
|
||||
Ok(ctl)
|
||||
}
|
||||
|
||||
/// A pipe's section: a circle of radius `r`, hollow when it has a wall.
|
||||
fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) -> Result<Region2> {
|
||||
let holes = match wall {
|
||||
@@ -3153,7 +3344,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3782,6 +3977,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::SheetContour { .. }
|
||||
| FeatureKind::SheetFlange { .. }
|
||||
| FeatureKind::SheetHem { .. }
|
||||
| FeatureKind::SheetPerforate { .. }
|
||||
| FeatureKind::SheetFold { .. }
|
||||
| FeatureKind::SheetUnfold { .. }
|
||||
| FeatureKind::SheetConvert { .. } => more::sheet_eval(doc, vals, f, st, warning),
|
||||
@@ -3799,7 +3995,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 { .. }
|
||||
@@ -3857,7 +4055,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
// Cuts through sheet metal become cut-outs of its flat pattern.
|
||||
if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
|
||||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||||
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref())?;
|
||||
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref(), warning)?;
|
||||
if !handled.is_empty() {
|
||||
return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled);
|
||||
}
|
||||
@@ -3867,7 +4065,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
FeatureKind::Hole { thread, diameter, .. } => {
|
||||
let (spots, dir) = hole_spots(st, f)?;
|
||||
let tools = hole_feature_tools(vals, st, f)?;
|
||||
let handled = sheet_hole_cut(st, f, &tools)?;
|
||||
let handled = sheet_hole_cut(st, f, &tools, warning)?;
|
||||
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
|
||||
if handled.is_empty() {
|
||||
apply_traced(st, f, tools, Operation::Cut, &[])?;
|
||||
@@ -4169,23 +4367,28 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
Some((_, Err(_))) | None => {
|
||||
let mut w = None;
|
||||
let refs: Vec<EdgeRef> = f.edge_refs.get(i).cloned().into_iter().collect();
|
||||
if let Ok(q) = resolve_edges(&mb.body, &[*p], &refs, &mut w) {
|
||||
let skipped =
|
||||
format!("edge `{}` no longer exists and was skipped", f.edge_names.get(i).map(String::as_str).unwrap_or("?"));
|
||||
// The name is gone. A pick on the axis of a round must not fall
|
||||
// through to the tangency seam (#775). A sharp edge that moved
|
||||
// still resolves from the point, which an edit relies on.
|
||||
if pick_lies_on_a_round_axis(&mb.body, *p) {
|
||||
notes.push(skipped);
|
||||
} else if let Ok(q) = resolve_edges(&mb.body, &[*p], &refs, &mut w) {
|
||||
out.extend(q);
|
||||
notes.push(match f.edge_names.get(i) {
|
||||
Some(n) if !n.is_empty() => format!("edge `{n}` no longer exists; using the edge at the picked point"),
|
||||
_ => "an edge reference without a name was found by its point".into(),
|
||||
});
|
||||
} else {
|
||||
notes.push(format!(
|
||||
"edge `{}` no longer exists and was skipped",
|
||||
f.edge_names.get(i).map(String::as_str).unwrap_or("?")
|
||||
));
|
||||
notes.push(skipped);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(DocError::Invalid("none of the selected edges exist any more".into()));
|
||||
let why = if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) };
|
||||
return Err(DocError::Invalid(format!("none of the selected edges exist any more{why}")));
|
||||
}
|
||||
if !notes.is_empty() {
|
||||
*warning = Some(notes.join("; "));
|
||||
@@ -4252,7 +4455,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let corner = point(vals, corner)?;
|
||||
let b = kernel::box_solid(corner, corner + s)?;
|
||||
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
|
||||
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false)?;
|
||||
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false, warning)?;
|
||||
let tools = vec![crate::lineage::box_tool(b)];
|
||||
if !handled.is_empty() {
|
||||
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
|
||||
@@ -4264,7 +4467,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let b = kernel::cylinder(base, *axis, radius, height)?;
|
||||
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b };
|
||||
let prism = more::PrimitivePrism::Cylinder { base, axis: *axis, radius, height };
|
||||
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation, false)?;
|
||||
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation, false, warning)?;
|
||||
let tools = vec![crate::lineage::cylinder_tool(b)];
|
||||
if !handled.is_empty() {
|
||||
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
|
||||
@@ -4621,9 +4824,61 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
|
||||
/// occurrence of it (bodies of the first placement keep their names; further instances get the
|
||||
/// occurrence path in brackets). Without components this is the model itself.
|
||||
pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
|
||||
world_state_reusing(doc, st, &mut PlacedBodies::default())
|
||||
}
|
||||
|
||||
/// Bodies a [`world_state_reusing`] call placed away from their definition (a moved
|
||||
/// component's bodies), by their definition and placement: the next call reuses each one whose
|
||||
/// definition and placement are unchanged instead of transforming it again, and so keeps its
|
||||
/// display mesh. Without this every change anywhere in a design transformed and meshed every
|
||||
/// body of every moved component again.
|
||||
#[derive(Default)]
|
||||
pub struct PlacedBodies {
|
||||
by_source: std::collections::HashMap<(usize, [u64; 16]), (ModelBody, ModelBody)>,
|
||||
}
|
||||
|
||||
impl PlacedBodies {
|
||||
/// The body `b` placed by `m`, from an earlier call when `b` is the same body as then.
|
||||
fn placed(&self, b: &ModelBody, m: &crate::Mat, name: String) -> ModelBody {
|
||||
let reused = self.by_source.get(&placed_key(b, m)).filter(|(source, _)| {
|
||||
Arc::ptr_eq(&source.mesh, &b.mesh)
|
||||
&& source.body.same(&b.body)
|
||||
&& source.body.color() == b.body.color()
|
||||
&& source.body.paint() == b.body.paint()
|
||||
&& match (source.body.split_lines(), b.body.split_lines()) {
|
||||
(Some(x), Some(y)) => Arc::ptr_eq(x, y),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
match reused {
|
||||
Some((_, placed)) => {
|
||||
let mut fresh = ModelBody::new(name, placed.body.clone(), b.feature);
|
||||
fresh.mesh = placed.mesh.clone();
|
||||
fresh.plan = placed.plan.clone();
|
||||
fresh.plan_mesh = placed.plan_mesh.clone();
|
||||
fresh
|
||||
}
|
||||
None => ModelBody::new(name, kernel::transform_matrix(&b.body, *m).unwrap_or_else(|_| b.body.clone()), b.feature),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn placed_key(b: &ModelBody, m: &crate::Mat) -> (usize, [u64; 16]) {
|
||||
let mut bits = [0u64; 16];
|
||||
for (slot, v) in bits.iter_mut().zip(m.iter().flatten()) {
|
||||
*slot = v.to_bits();
|
||||
}
|
||||
(Arc::as_ptr(&b.mesh) as usize, bits)
|
||||
}
|
||||
|
||||
/// [`world_state`], reusing the bodies `placed` holds from an earlier call and leaving it holding
|
||||
/// this call's.
|
||||
pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut PlacedBodies) -> ModelState {
|
||||
if doc.occurrences.is_empty() {
|
||||
return st.clone();
|
||||
}
|
||||
let mut placed_now = PlacedBodies::default();
|
||||
let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let first_path = |comp: u64| -> Vec<u64> {
|
||||
let mut path = Vec::new();
|
||||
@@ -4653,8 +4908,9 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
|
||||
placed.name = name;
|
||||
placed
|
||||
} else {
|
||||
let body = kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone());
|
||||
ModelBody::new(name, body, b.feature)
|
||||
let placed = placed_before.placed(b, &bm, name);
|
||||
placed_now.by_source.insert(placed_key(b, &bm), (b.clone(), placed.clone()));
|
||||
placed
|
||||
};
|
||||
// World states keep their previous display-only evaluation-key semantics.
|
||||
placed.key = 0;
|
||||
@@ -4702,6 +4958,7 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
|
||||
}
|
||||
}
|
||||
}
|
||||
*placed_before = placed_now;
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
+332
-81
@@ -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.
|
||||
@@ -485,6 +494,32 @@ pub(super) fn sheet_eval(
|
||||
tag_flanges(sh, n0, f.id);
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
FeatureKind::SheetPerforate { diameter, pitch, margin, pattern, body } => {
|
||||
// Without a body name the field goes on the newest sheet, as the command's own body lookup does.
|
||||
let i = match body {
|
||||
Some(_) => sheet_for(st, body, None)?,
|
||||
None => st.sheets.len().checked_sub(1).ok_or_else(|| DocError::Invalid("there is no sheet metal body".into()))?,
|
||||
};
|
||||
let d = val(vals, diameter, Kind::Length)?;
|
||||
let pitch = val(vals, pitch, Kind::Length)?;
|
||||
let margin = match margin {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => d,
|
||||
};
|
||||
let square = match pattern.as_deref() {
|
||||
None | Some("staggered") => false,
|
||||
Some("square") => true,
|
||||
Some(o) => return Err(DocError::Invalid(format!("perforation pattern {o:?}: use \"staggered\" or \"square\""))),
|
||||
};
|
||||
if !(d > 0.0 && pitch > d && margin >= 0.0) {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"perforation needs diameter > 0, pitch > diameter and margin >= 0 (diameter {d}, pitch {pitch}, margin {margin})"
|
||||
)));
|
||||
}
|
||||
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square });
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
|
||||
let i = sheet_for(st, body, edges.first().copied())?;
|
||||
let l = val(vals, length, Kind::Length)?;
|
||||
@@ -583,33 +618,49 @@ pub(super) fn sheet_eval(
|
||||
None => *p,
|
||||
});
|
||||
}
|
||||
let mut sh = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
|
||||
let (mut sh, notes) = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
|
||||
for note in notes {
|
||||
append_warning(warning, note);
|
||||
}
|
||||
tag_flanges(&mut sh, 0, f.id);
|
||||
put_sheet(st, sh)
|
||||
}
|
||||
FeatureKind::SheetConvert { body, face, rule, .. } => {
|
||||
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?;
|
||||
let tol = (mb.body.size() * 1e-3).max(1e-3);
|
||||
let mesh = mb.body.tessellate(tol)?;
|
||||
// Thickness: the body's extent along the face normal.
|
||||
let ds: Vec<f64> = mesh.positions.iter().map(|p| (*p - *face).dot(n)).collect();
|
||||
let (lo, hi) = (ds.iter().cloned().fold(f64::MAX, f64::min), ds.iter().cloned().fold(f64::MIN, f64::max));
|
||||
let extent = |mesh: &solidcraft_geom::Mesh| {
|
||||
mesh.positions.iter().map(|p| (*p - *face).dot(n)).fold((f64::MAX, f64::MIN), |(lo, hi), d| (lo.min(d), hi.max(d)))
|
||||
};
|
||||
// The tolerance (the mesh's chords, and how far off the face's plane a point of it may
|
||||
// be) is at most a tenth of the thickness, not only a thousandth of the size. On a long
|
||||
// thin plate that thousandth passes the thickness: the far side's triangles counted as
|
||||
// this face's (no outline was left), and a hole of a smaller radius than that fell out.
|
||||
let mut tol = (mb.body.size() * 1e-3).max(1e-3);
|
||||
let mut mesh = mb.body.tessellate(tol)?;
|
||||
let (mut lo, mut hi) = extent(&mesh);
|
||||
if hi - lo > 0.0 && tol > (hi - lo) * 0.1 {
|
||||
tol = ((hi - lo) * 0.1).max(1e-6);
|
||||
mesh = mb.body.tessellate(tol)?;
|
||||
(lo, hi) = extent(&mesh);
|
||||
}
|
||||
let t = hi - lo;
|
||||
// The face outline: boundary edges of the triangles on that face's plane, where it runs
|
||||
// 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(),
|
||||
@@ -620,6 +671,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))),
|
||||
@@ -683,7 +738,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);
|
||||
@@ -705,6 +764,7 @@ pub(super) fn sheet_cut(
|
||||
tools: &[Body],
|
||||
targets: &[String],
|
||||
constructor: Option<&ExtrudeConstructor>,
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
|
||||
if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) {
|
||||
@@ -712,7 +772,16 @@ pub(super) fn sheet_cut(
|
||||
}
|
||||
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
sheet_prism_cut(f, st, &ss.plane, ®ions, *operation, tools, targets, constructor, extent.to.is_some())
|
||||
// The outlines of the sketch's text: the sheet remembers where its letters went.
|
||||
let text: Vec<Seg2> = ss
|
||||
.sketch
|
||||
.links
|
||||
.iter()
|
||||
.filter(|l| matches!(l.source, solidcraft_sketch::LinkSource::Text { .. }))
|
||||
.flat_map(|l| ss.sketch.link_curves(&l.id))
|
||||
.flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
|
||||
.collect();
|
||||
sheet_prism_cut(f, st, &ss.plane, ®ions, *operation, tools, targets, constructor, extent.to.is_some(), &text, warning)
|
||||
}
|
||||
|
||||
/// Whether a cut of `f` with these targets (else its participants, else its component's
|
||||
@@ -744,6 +813,7 @@ pub(super) fn sheet_primitive_cut(
|
||||
prism: PrimitivePrism,
|
||||
operation: Operation,
|
||||
drill: bool,
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) {
|
||||
return Ok(Vec::new());
|
||||
@@ -768,7 +838,7 @@ pub(super) fn sheet_primitive_cut(
|
||||
let Some(normal) = normal else { return Ok(Vec::new()) };
|
||||
let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) };
|
||||
let tools = kernel::extrude(&plane, std::slice::from_ref(®ion), interval.0, interval.1)?;
|
||||
sheet_prism_cut(f, st, &plane, std::slice::from_ref(®ion), operation, &tools, &[], None, false)
|
||||
sheet_prism_cut(f, st, &plane, std::slice::from_ref(®ion), operation, &tools, &[], None, false, &[], warning)
|
||||
}
|
||||
|
||||
/// A hole's straight drill along a panel of a sheet (into its rim), kept as a bore of each
|
||||
@@ -855,6 +925,12 @@ impl PrimitivePrism {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most outline segments of a sketch's text a cut tells letters by, and the most text regions
|
||||
/// a sheet keeps (see [`crate::sheet::SheetBody::labels`]).
|
||||
const MAX_TEXT_CURVES: usize = 20_000;
|
||||
const MAX_LABELS: usize = 1000;
|
||||
|
||||
/// `text` is the outlines of the text in the cut's sketch (none for a primitive's cut).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sheet_prism_cut(
|
||||
f: &Feature,
|
||||
@@ -866,6 +942,8 @@ fn sheet_prism_cut(
|
||||
targets: &[String],
|
||||
constructor: Option<&ExtrudeConstructor>,
|
||||
footprints_bound: bool,
|
||||
text: &[Seg2],
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
let n = plane.normal();
|
||||
let mut work = sheet::FlatWork::new();
|
||||
@@ -904,7 +982,14 @@ fn sheet_prism_cut(
|
||||
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
|
||||
.then_some(c.interval)
|
||||
});
|
||||
// The regions of the cut that are letters, with their counters.
|
||||
let letters: Vec<&Region2> = if operation == Operation::Cut && !text.is_empty() && text.len() <= MAX_TEXT_CURVES {
|
||||
regions.iter().filter(|r| sheet::loop_lies_on(&r.outer, text)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut plans = Vec::new();
|
||||
let mut notes = Vec::new();
|
||||
'sheets: for original in &st.sheets {
|
||||
if !sheet_eligible(f, st, targets, original) {
|
||||
continue;
|
||||
@@ -937,6 +1022,7 @@ fn sheet_prism_cut(
|
||||
sh.material_panels(Some(plane), &mut work)?
|
||||
};
|
||||
let mut modeled = false;
|
||||
let mut labels = Vec::new();
|
||||
for (pi, panel) in candidates {
|
||||
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
|
||||
let zorigin = (apply_point(&w, Vec3::ZERO) - plane.origin).dot(n);
|
||||
@@ -976,6 +1062,7 @@ fn sheet_prism_cut(
|
||||
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
|
||||
// Sub-resolution layers must not be silently erased by endpoint snapping.
|
||||
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
|
||||
labels.extend(letters.iter().filter_map(|r| sh.region_to_flat(pi, plane, r).ok()));
|
||||
let mapped = if footprints_bound && current_footprints.len() == tools.len() {
|
||||
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
|
||||
} else {
|
||||
@@ -1040,19 +1127,34 @@ fn sheet_prism_cut(
|
||||
}
|
||||
}
|
||||
}
|
||||
if modeled {
|
||||
for flat in labels {
|
||||
if !sh.labels.contains(&flat) && sh.labels.len() < MAX_LABELS {
|
||||
sh.labels.push(flat);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !modeled {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
} else if sh.flat_material()?.is_empty() {
|
||||
// Empty exact material removes geometry and metadata together. Budget or
|
||||
// topology errors propagate; they are never mistaken for an empty blank.
|
||||
plans.push((sh.body.clone(), None));
|
||||
} else {
|
||||
let body = sh.solid()?;
|
||||
plans.push((sh.body.clone(), Some((sh, body))));
|
||||
let material = sh.flat_material()?;
|
||||
if material.is_empty() {
|
||||
// Empty exact material removes geometry and metadata together. Budget or
|
||||
// topology errors propagate; they are never mistaken for an empty blank.
|
||||
plans.push((sh.body.clone(), None));
|
||||
} else {
|
||||
// Every piece the cut leaves stays; the ones it parts from the base are named.
|
||||
notes.extend(parted_note(&sh, &material, original));
|
||||
let body = sh.solid()?;
|
||||
plans.push((sh.body.clone(), Some((sh, body))));
|
||||
}
|
||||
}
|
||||
}
|
||||
let handled = plans.iter().map(|(name, _)| name.clone()).collect();
|
||||
if !notes.is_empty() {
|
||||
append_warning(warning, notes.join("; "));
|
||||
}
|
||||
for (name, result) in plans {
|
||||
match result {
|
||||
Some((sh, body)) => {
|
||||
@@ -1073,6 +1175,35 @@ fn sheet_prism_cut(
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
/// A cut's note on the pieces it parted from the base of `sh` (see
|
||||
/// [`sheet::SheetBody::parted_from_base`]; `material` is the sheet's flat material after the cut,
|
||||
/// `before` the sheet before it): how many, their area and where they lie in the flat pattern.
|
||||
/// The pieces stay, as every piece a cut leaves does. None when nothing is parted, and when the
|
||||
/// pieces can't be worked out: the note is no reason for a cut to fail.
|
||||
fn parted_note(sh: &sheet::SheetBody, material: &[Region2], before: &sheet::SheetBody) -> Option<String> {
|
||||
let pieces = sh.parted_from_base(material, before).ok().filter(|p| !p.is_empty())?;
|
||||
let area: f64 = pieces.iter().map(Region2::area).sum();
|
||||
let at: Vec<String> = pieces
|
||||
.iter()
|
||||
.take(4)
|
||||
.map(|r| {
|
||||
let c = r.interior_point();
|
||||
format!("({:.1}, {:.1})", c.x, c.y)
|
||||
})
|
||||
.collect();
|
||||
let at = at.join(", ");
|
||||
let body = &sh.body;
|
||||
Some(match pieces.len() {
|
||||
1 => format!("1 piece of `{body}` is no longer joined to its base ({area:.2} mm², near {at} in the flat pattern); it is kept"),
|
||||
n => {
|
||||
let more = if n > 4 { format!(" and {} more", n - 4) } else { String::new() };
|
||||
format!(
|
||||
"{n} pieces of `{body}` are no longer joined to its base ({area:.2} mm² in all, near {at}{more} in the flat pattern); they are kept"
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
|
||||
/// `level` from `at` along `n`: the edges used by one of them only, chained.
|
||||
fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
@@ -1084,18 +1215,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();
|
||||
@@ -1119,6 +1240,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();
|
||||
@@ -1157,60 +1306,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);
|
||||
}
|
||||
@@ -156,7 +176,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path } => {
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale } => {
|
||||
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale)?;
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
|
||||
let ps =
|
||||
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
|
||||
@@ -175,8 +196,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
let pts: Vec<Vec2> = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect();
|
||||
let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64));
|
||||
let body = match path_points(ps, path, start)? {
|
||||
Some(pts) => kernel::sweep_sheet_points(&ss.plane, &chains, &pts)?,
|
||||
None => kernel::sweep_sheet(&ss.plane, &chains, &path_segments(ps, path, start)?)?,
|
||||
Some(pts) => kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)?,
|
||||
None => kernel::sweep_sheet_with(&ss.plane, &chains, &path_segments(ps, path, start)?, &ctl)?,
|
||||
};
|
||||
let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body);
|
||||
push_sheet(st, f, traced);
|
||||
@@ -234,6 +255,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())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +446,7 @@ mod tests {
|
||||
let producer = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: source,
|
||||
profiles: crate::ProfileSel::Indices { indices: vec![0, 1] },
|
||||
extent,
|
||||
@@ -555,7 +556,14 @@ mod tests {
|
||||
let extent = serde_json::from_value(serde_json::json!({"distance":"2"})).unwrap();
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude { sketch: sk, profiles: crate::ProfileSel::All, extent, operation: crate::Operation::NewBody, targets: vec![] },
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent,
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
Some("Boss"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! 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.
|
||||
//! The walk (`crate::walk`) is behind `transform_geometry`, `face_points`, `profile_sels` and
|
||||
//! `point_expr`.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
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> {
|
||||
/// A point that picks a face: it moves with the feature and names the face it picks.
|
||||
Pick(V),
|
||||
/// A point that picks a face but stays where it is when the feature is moved (an Emboss
|
||||
/// cylinder, chosen in its component's frame).
|
||||
Pinned(V),
|
||||
/// Any other position (an edge pick, an axis origin, a fold line): it moves with the feature.
|
||||
Pos(V),
|
||||
/// A direction: it turns with the feature.
|
||||
Dir(V),
|
||||
Plane(P),
|
||||
/// A construction axis's or point's reference to the model.
|
||||
Construct(G),
|
||||
/// A primitive's placement point (length expressions).
|
||||
Placement(E),
|
||||
/// A profile selection, with its sketch.
|
||||
Profiles(u64, S),
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
/// 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> {
|
||||
let mut v = Vec::new();
|
||||
self.walk_geometry_mut(&mut |g| {
|
||||
if let Geo::Pick(p) | Geo::Pinned(p) = g {
|
||||
v.push(p);
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
/// Read-only view of [`FeatureKind::face_points_mut`].
|
||||
pub fn face_points(&self) -> Vec<Vec3> {
|
||||
let mut v = Vec::new();
|
||||
self.walk_geometry(&mut |g| {
|
||||
if let Geo::Pick(p) | Geo::Pinned(p) = g {
|
||||
v.push(*p);
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
/// The feature's profile selections, each with its sketch.
|
||||
pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> {
|
||||
let mut v = Vec::new();
|
||||
self.walk_geometry_mut(&mut |g| {
|
||||
if let Geo::Profiles(sketch, sel) = g {
|
||||
v.push((sketch, sel));
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
/// Read-only view of [`FeatureKind::profile_sels_mut`].
|
||||
pub fn profile_sels(&self) -> Vec<(u64, &ProfileSel)> {
|
||||
let mut v = Vec::new();
|
||||
self.walk_geometry(&mut |g| {
|
||||
if let Geo::Profiles(sketch, sel) = g {
|
||||
v.push((sketch, sel));
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
/// A primitive's placement point (length expressions). Its coordinates are not inputs:
|
||||
/// they get no parameter names and keep bare numbers in mm whatever the design's units, as
|
||||
/// every other position does; renames and users still find them.
|
||||
pub fn point_expr_mut(&mut self) -> Option<&mut PointExpr> {
|
||||
let mut found = None;
|
||||
self.walk_geometry_mut(&mut |g| {
|
||||
if let Geo::Placement(p) = g {
|
||||
found = Some(p);
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// Read-only view of [`FeatureKind::point_expr_mut`].
|
||||
pub fn point_expr(&self) -> Option<&PointExpr> {
|
||||
let mut found = None;
|
||||
self.walk_geometry(&mut |g| {
|
||||
if let Geo::Placement(p) = g {
|
||||
found = Some(p);
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! 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). The walk (`crate::walk`) is behind
|
||||
//! `input_sketches`, `remap_refs` and `remap_bodies`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
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).
|
||||
pub(crate) enum Ref<S, N> {
|
||||
Sketch(S),
|
||||
Feature(N),
|
||||
Body(N),
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
/// Sketches the feature is built from (profiles, paths, points): deleting one of them
|
||||
/// deletes the feature.
|
||||
pub fn input_sketches(&self) -> Vec<u64> {
|
||||
let mut v = Vec::new();
|
||||
self.walk_refs(&mut |r| {
|
||||
if let Ref::Sketch(id) = r {
|
||||
v.push(*id);
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
/// Point references to sketches (by id) and features (by name) at their copies, or at a
|
||||
/// feature's new name (`timeline.rename`).
|
||||
pub fn remap_refs(&mut self, sketches: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>) {
|
||||
self.walk_refs_mut(&mut |r| match r {
|
||||
Ref::Sketch(id) => {
|
||||
if let Some(n) = sketches.get(id) {
|
||||
*id = *n;
|
||||
}
|
||||
}
|
||||
Ref::Feature(name) => {
|
||||
if let Some(n) = names.get(name) {
|
||||
*name = n.clone();
|
||||
}
|
||||
}
|
||||
Ref::Body(_) => {}
|
||||
});
|
||||
}
|
||||
|
||||
/// Point references to bodies (by name) at their copies.
|
||||
pub fn remap_bodies(&mut self, bodies: &BTreeMap<String, String>) {
|
||||
self.walk_refs_mut(&mut |r| {
|
||||
if let Ref::Body(name) = r
|
||||
&& let Some(n) = bodies.get(name)
|
||||
{
|
||||
*name = n.clone();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Golden records of what the walks over a feature report, for every variant (and every fillet
|
||||
//! style, pattern, hole and construction definition):
|
||||
//! - expressions: `inputs` (label, expression, kind, in order: the order names the `dN`
|
||||
//! parameters) and `expressions` (every expression, placement points included). Each
|
||||
//! expression field holds its own name, so a moved, missing or relabelled input shows;
|
||||
//! - references: the sketches a feature is built from, and the fields `remap_refs` and
|
||||
//! `remap_bodies` rewrite when everything is copied under new ids and names;
|
||||
//! - geometry: the fields `transform_geometry` moves, the face points, the profile selections and
|
||||
//! the placement point.
|
||||
//!
|
||||
//! The records live in `feature_walk_tests/*.txt`; to record an intended change, run the tests
|
||||
//! with `FEATURE_WALK_BLESS=1`.
|
||||
|
||||
mod fixtures;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use fixtures::{changed, fixtures, with_points, with_refs};
|
||||
|
||||
use crate::FeatureKind;
|
||||
|
||||
/// Per fixture: its inputs, one per line (`label = expression (kind)`), then every expression.
|
||||
fn record() -> String {
|
||||
let mut out = String::new();
|
||||
for (name, v) in fixtures() {
|
||||
let kind: FeatureKind = serde_json::from_value(v).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
out += &format!("{name}\n");
|
||||
let inputs = kind.inputs();
|
||||
if !inputs.is_empty() {
|
||||
out += " inputs:\n";
|
||||
for (label, e, k) in inputs {
|
||||
out += &format!(" {label} = {e} ({k:?})\n");
|
||||
}
|
||||
}
|
||||
let expressions = kind.expressions();
|
||||
if !expressions.is_empty() {
|
||||
out += &format!(" expressions: {}\n", expressions.join(", "));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Per fixture: the sketches it is built from, and the fields that copying with every sketch,
|
||||
/// feature and body renamed rewrites (`remap_refs`: sketches and features; `remap_bodies`).
|
||||
fn ref_record() -> String {
|
||||
let sketches: BTreeMap<u64, u64> = (1..=8).map(|i| (i, i + 100)).collect();
|
||||
let names = BTreeMap::from([("F".to_string(), "F2".to_string()), ("CP".to_string(), "CP2".to_string())]);
|
||||
let bodies = BTreeMap::from([("B".to_string(), "B2".to_string())]);
|
||||
let mut out = String::new();
|
||||
for (name, v) in fixtures() {
|
||||
let kind: FeatureKind = serde_json::from_value(with_refs(name, v)).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let before = serde_json::to_value(&kind).unwrap();
|
||||
let mut refs = kind.clone();
|
||||
refs.remap_refs(&sketches, &names);
|
||||
let mut by_refs = Vec::new();
|
||||
changed(&before, &serde_json::to_value(&refs).unwrap(), "", &mut by_refs);
|
||||
let mut body = kind.clone();
|
||||
body.remap_bodies(&bodies);
|
||||
let mut by_bodies = Vec::new();
|
||||
changed(&before, &serde_json::to_value(&body).unwrap(), "", &mut by_bodies);
|
||||
out += &format!("{name}\n");
|
||||
let sketches = kind.input_sketches();
|
||||
if !sketches.is_empty() {
|
||||
out += &format!(" sketches: {sketches:?}\n");
|
||||
}
|
||||
if !by_refs.is_empty() {
|
||||
out += &format!(" remap_refs: {}\n", by_refs.join(", "));
|
||||
}
|
||||
if !by_bodies.is_empty() {
|
||||
out += &format!(" remap_bodies: {}\n", by_bodies.join(", "));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A variant with a field that refers to a sketch (a field named `*sketch*` holding an id, sketch
|
||||
/// points or loft sections) must report it from `input_sketches`, or deleting the sketch leaves
|
||||
/// the feature broken. New variants are caught by the walk's exhaustive match; this catches a
|
||||
/// sketch field added to an existing variant (its fixture has to set it).
|
||||
/// Per fixture (with every reference and position set): the fields `transform_geometry` changes
|
||||
/// under a turn and a shift (every point, direction and plane it maps), the face points that name
|
||||
/// faces, the profile selections with their sketches, and a primitive's placement point.
|
||||
fn geometry_record() -> String {
|
||||
// x → y → z → x, then (1, 2, 3): every point and every direction of the fixtures changes.
|
||||
let m = [[0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [1.0, 0.0, 0.0, 0.0], [1.0, 2.0, 3.0, 1.0]];
|
||||
let mut out = String::new();
|
||||
for (name, v) in fixtures() {
|
||||
let kind: FeatureKind = serde_json::from_value(with_points(name, with_refs(name, v))).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let mut moved = kind.clone();
|
||||
moved.transform_geometry(&m);
|
||||
let mut paths = Vec::new();
|
||||
changed(&serde_json::to_value(&kind).unwrap(), &serde_json::to_value(&moved).unwrap(), "", &mut paths);
|
||||
out += &format!("{name}\n");
|
||||
if !paths.is_empty() {
|
||||
out += &format!(" moves: {}\n", paths.join(", "));
|
||||
}
|
||||
let faces: Vec<String> = kind.face_points().iter().map(|p| format!("[{}, {}, {}]", p.x, p.y, p.z)).collect();
|
||||
if !faces.is_empty() {
|
||||
out += &format!(" face points: {}\n", faces.join(", "));
|
||||
}
|
||||
let profiles: Vec<String> = kind
|
||||
.profile_sels()
|
||||
.iter()
|
||||
.map(|(sketch, sel)| format!("{sketch} {}", serde_json::to_value(sel).unwrap()["type"].as_str().unwrap_or("?")))
|
||||
.collect();
|
||||
if !profiles.is_empty() {
|
||||
out += &format!(" profiles: {}\n", profiles.join(", "));
|
||||
}
|
||||
if let Some(p) = kind.point_expr() {
|
||||
out += &format!(" placement: {}\n", p.join(", "));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compares `got` with the golden file `name` in `feature_walk_tests/`, line by line. With
|
||||
/// `FEATURE_WALK_BLESS` set it rewrites the file instead, for an intended change (say why in the
|
||||
/// pull request).
|
||||
fn check_golden(name: &str, want: &str, got: &str) {
|
||||
if std::env::var_os("FEATURE_WALK_BLESS").is_some() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/feature_walk_tests").join(name);
|
||||
std::fs::write(&path, got).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
return;
|
||||
}
|
||||
for (line, (want, got)) in want.lines().zip(got.lines()).enumerate() {
|
||||
assert_eq!(want, got, "{name} line {}; rerun with FEATURE_WALK_BLESS=1 if the change is intended", line + 1);
|
||||
}
|
||||
assert_eq!(want.lines().count(), got.lines().count(), "{name}: the record has a different number of lines");
|
||||
}
|
||||
|
||||
/// Recorded from the hand-written `inputs_mut` and `expressions` the walk replaced.
|
||||
const GOLDEN_EXPRS: &str = include_str!("feature_walk_tests/golden_exprs.txt");
|
||||
|
||||
/// Recorded from the hand-written `transform_geometry`, `face_points`, `profile_sels` and
|
||||
/// `point_expr` the walk replaced.
|
||||
const GOLDEN_GEOMETRY: &str = include_str!("feature_walk_tests/golden_geometry.txt");
|
||||
|
||||
/// Recorded from the hand-written `input_sketches`, `remap_refs` and `remap_bodies` the walk
|
||||
/// replaced, with two fixes: a coil's `targets` and a surface trim's plane are remapped too.
|
||||
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").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();
|
||||
for line in body.lines() {
|
||||
let t = line.trim();
|
||||
if line.starts_with(" ") && !line.starts_with(" ") && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
|
||||
variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("").to_lowercase();
|
||||
continue;
|
||||
}
|
||||
let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
|
||||
let refers = (name.contains("sketch") && ty.contains("u64")) || ty.contains("SketchPoints") || ty.contains("LoftSection");
|
||||
if refers && !t.starts_with("//") && !with_sketch.contains(&variant) {
|
||||
with_sketch.push(variant.clone());
|
||||
}
|
||||
}
|
||||
assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}");
|
||||
for v in with_sketch {
|
||||
let uses = fixtures().into_iter().filter(|(_, f)| f["type"].as_str().unwrap().replace('_', "") == v).any(|(name, f)| {
|
||||
let kind: FeatureKind = serde_json::from_value(with_refs(name, f)).unwrap();
|
||||
!kind.input_sketches().is_empty()
|
||||
});
|
||||
assert!(uses, "FeatureKind::{v} has a sketch field but input_sketches reports no sketch for its fixtures");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_matches_the_golden_record() {
|
||||
check_golden("golden_geometry.txt", GOLDEN_GEOMETRY, &geometry_record());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_match_the_golden_record() {
|
||||
check_golden("golden_refs.txt", GOLDEN_REFS, &ref_record());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inputs_and_expressions_match_the_golden_record() {
|
||||
check_golden("golden_exprs.txt", GOLDEN_EXPRS, &record());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_record_covers_every_variant() {
|
||||
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 names: Vec<String> = fixtures().into_iter().map(|(_, v)| v["type"].as_str().unwrap().replace('_', "")).collect();
|
||||
for line in body.lines().filter(|l| l.starts_with(" ") && !l.starts_with(" ")) {
|
||||
let t = line.trim();
|
||||
if t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
|
||||
let variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("").to_lowercase();
|
||||
assert!(names.contains(&variant), "FeatureKind::{t} has no fixture in feature_walk_tests");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Fixtures for the golden records in `feature_walk_tests`: one feature per variant (and per
|
||||
//! fillet style, pattern, hole kind and construction definition), each expression field holding
|
||||
//! its own name, plus the reference fields `with_refs` sets.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn plane(chain: &str) -> Value {
|
||||
json!({"type": "at_angle", "angle": format!("{chain}.angle"), "axis_origin": [0, 0, 0], "axis_dir": [1, 0, 0],
|
||||
"base": {"type": "offset", "distance": format!("{chain}.offset"), "base": {"type": "construction", "name": "CP"}}})
|
||||
}
|
||||
|
||||
fn geo_plane(chain: &str) -> Value {
|
||||
json!({"type": "plane", "plane": plane(chain)})
|
||||
}
|
||||
|
||||
fn along(chain: &str) -> Value {
|
||||
json!({"type": "along_path", "path": {"type": "edge", "at": [0, 0, 0]}, "t": format!("{chain}.t")})
|
||||
}
|
||||
|
||||
pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
let o = [0, 0, 0];
|
||||
let sk = serde_json::to_value(solidcraft_sketch::Sketch::new()).unwrap();
|
||||
let extent = |n: &str| {
|
||||
json!({"distance": format!("{n}.distance"), "distance2": format!("{n}.distance2"), "start_offset": format!("{n}.start_offset"),
|
||||
"taper": format!("{n}.taper"), "to_offset": format!("{n}.to_offset")})
|
||||
};
|
||||
let fillet = |style: Value| json!({"type": "fillet", "edges": [o], "radius": "fillet.radius", "style": style});
|
||||
let pattern = |p: Value| json!({"type": "pattern", "features": ["F"], "pattern": p});
|
||||
let hole =
|
||||
|h: Value| json!({"type": "hole", "position": o, "direction": [0, 0, 1], "diameter": "hole.diameter", "depth": "hole.depth", "hole": h});
|
||||
vec![
|
||||
(
|
||||
"body_transfer",
|
||||
json!({"type": "body_transfer", "bodies": ["B"], "from": 0, "to": 1, "transform": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}),
|
||||
),
|
||||
("sketch", json!({"type": "sketch", "plane": plane("sketch"), "sketch": sk.clone()})),
|
||||
("sketch_along_path", json!({"type": "sketch", "plane": along("sketch"), "sketch": sk})),
|
||||
("extrude", json!({"type": "extrude", "sketch": 1, "profiles": {"type": "all"}, "extent": extent("extrude")})),
|
||||
(
|
||||
"extrude_thin",
|
||||
json!({"type": "extrude", "sketch": 1, "profiles": {"type": "all"}, "extent": extent("thin"),
|
||||
"thin": {"curves": ["c1"], "thickness": "thin.thickness", "side": "two_sides", "thickness2": "thin.thickness2"}}),
|
||||
),
|
||||
(
|
||||
"revolve",
|
||||
json!({"type": "revolve", "sketch": 1, "profiles": {"type": "all"}, "axis": {"type": "world", "axis": "X"}, "angle": "revolve.angle", "angle2": "revolve.angle2"}),
|
||||
),
|
||||
("fillet_constant", fillet(json!({"type": "constant"}))),
|
||||
("fillet_chord", fillet(json!({"type": "chord"}))),
|
||||
("fillet_curvature", fillet(json!({"type": "curvature"}))),
|
||||
("fillet_variable", fillet(json!({"type": "variable", "radius2": "fillet.radius2", "start": o, "mid": [[0.5, "fillet.mid"]]}))),
|
||||
("fillet_spline", fillet(json!({"type": "spline", "start": o, "knots": [0.0, 1.0], "radii": ["fillet.r1", "fillet.r2", "fillet.r3"]}))),
|
||||
(
|
||||
"chamfer",
|
||||
json!({"type": "chamfer", "edges": [o], "distance": "chamfer.distance", "distance2": "chamfer.distance2", "angle": "chamfer.angle"}),
|
||||
),
|
||||
("box", json!({"type": "box", "corner": ["box.x", "box.y", "box.z"], "length": "box.length", "width": "box.width", "height": "box.height"})),
|
||||
("cylinder", json!({"type": "cylinder", "base": ["cyl.x", "cyl.y", "cyl.z"], "radius": "cyl.radius", "height": "cyl.height"})),
|
||||
("sphere", json!({"type": "sphere", "center": ["sph.x", "sph.y", "sph.z"], "radius": "sph.radius"})),
|
||||
("torus", json!({"type": "torus", "center": ["tor.x", "tor.y", "tor.z"], "major": "tor.major", "minor": "tor.minor"})),
|
||||
("combine", json!({"type": "combine", "target": "A", "tools": ["B"], "operation": "join"})),
|
||||
(
|
||||
"pattern_rectangular",
|
||||
pattern(
|
||||
json!({"type": "rectangular", "dir1": [1, 0, 0], "count1": "rect.count1", "spacing1": "rect.spacing1", "dir2": [0, 1, 0], "count2": "rect.count2", "spacing2": "rect.spacing2"}),
|
||||
),
|
||||
),
|
||||
("pattern_circular", pattern(json!({"type": "circular", "origin": o, "axis": [0, 0, 1], "count": "circ.count", "angle": "circ.angle"}))),
|
||||
("pattern_path", pattern(json!({"type": "path", "path_sketch": 1, "path": ["l1"], "count": "path.count", "spacing": "path.spacing"}))),
|
||||
("mirror", json!({"type": "mirror", "features": ["F"], "plane": plane("mirror")})),
|
||||
("hole_simple", hole(json!({"type": "simple"}))),
|
||||
("hole_drilled", hole(json!({"type": "drilled", "tip_angle": "hole.tip_angle"}))),
|
||||
("hole_counterbore", hole(json!({"type": "counterbore", "cb_diameter": "hole.cb_diameter", "cb_depth": "hole.cb_depth"}))),
|
||||
("hole_countersink", hole(json!({"type": "countersink", "cs_diameter": "hole.cs_diameter", "cs_angle": "hole.cs_angle"}))),
|
||||
("thread", json!({"type": "thread", "face": o, "length": "thread.length"})),
|
||||
("loft", json!({"type": "loft", "sections": []})),
|
||||
("sweep", json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"]})),
|
||||
(
|
||||
"sweep_options",
|
||||
json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"],
|
||||
"orientation": {"direction": [0, 1, 0]}, "twist": "sweep.twist", "scale": "sweep.scale"}),
|
||||
),
|
||||
("shell", json!({"type": "shell", "faces": [o], "thickness": "shell.thickness"})),
|
||||
("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})),
|
||||
("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})),
|
||||
("split", json!({"type": "split", "body": "B", "plane": plane("split")})),
|
||||
(
|
||||
"construction_axis",
|
||||
json!({"type": "construction_axis", "def": {"type": "two_planes", "a": geo_plane("axis_a"), "b": geo_plane("axis_b")}}),
|
||||
),
|
||||
(
|
||||
"construction_point_planes",
|
||||
json!({"type": "construction_point", "def": {"type": "three_planes", "a": geo_plane("pt_a"), "b": geo_plane("pt_b"), "c": geo_plane("pt_c")}}),
|
||||
),
|
||||
(
|
||||
"construction_point_along",
|
||||
json!({"type": "construction_point", "def": {"type": "along_path", "path": {"type": "edge", "at": o}, "t": "pt.t"}}),
|
||||
),
|
||||
("split_face", json!({"type": "split_face", "faces": [o], "plane": plane("split_face")})),
|
||||
("scale", json!({"type": "scale", "bodies": ["B"], "factor": "scale.factor", "factors": ["scale.x", "scale.y", "scale.z"]})),
|
||||
("offset_face", json!({"type": "offset_face", "faces": [o], "distance": "offset_face.distance"})),
|
||||
("delete_face", json!({"type": "delete_face", "faces": [o]})),
|
||||
("bounding_solid", json!({"type": "bounding_solid", "bodies": ["B"], "margin": "bounding.margin"})),
|
||||
("pipe", json!({"type": "pipe", "path_sketch": 1, "path": ["l1"], "diameter": "pipe.diameter", "wall": "pipe.wall"})),
|
||||
("emboss", json!({"type": "emboss", "sketch": 1, "profiles": {"type": "all"}, "depth": "emboss.depth"})),
|
||||
("rib", json!({"type": "rib", "sketch": 1, "curves": ["l1"], "thickness": "rib.thickness", "depth": "rib.depth"})),
|
||||
("replace_face", json!({"type": "replace_face", "faces": [o], "target": plane("replace_face")})),
|
||||
("align", json!({"type": "align", "bodies": ["B"], "from": o, "to": o})),
|
||||
(
|
||||
"coil",
|
||||
json!({"type": "coil", "base": ["coil.x", "coil.y", "coil.z"], "diameter": "coil.diameter", "pitch": "coil.pitch", "turns": "coil.turns", "section_size": "coil.section_size", "start_angle": "coil.start_angle"}),
|
||||
),
|
||||
("sheet_base", json!({"type": "sheet_base", "sketch": 1, "profiles": {"type": "all"}})),
|
||||
("sheet_contour", json!({"type": "sheet_contour", "sketch": 1, "curves": ["l1"], "distance": "contour.distance"})),
|
||||
(
|
||||
"sheet_flange",
|
||||
json!({"type": "sheet_flange", "edges": [{"point": o, "angle": "flange.e1_angle", "height": "flange.e1_height"}, {"point": o, "angle": null, "height": "flange.e2_height"}], "height": "flange.height", "angle": "flange.angle", "radius": "flange.radius"}),
|
||||
),
|
||||
("sheet_hem", json!({"type": "sheet_hem", "edges": [o], "length": "hem.length", "gap": "hem.gap"})),
|
||||
(
|
||||
"sheet_perforate",
|
||||
json!({"type": "sheet_perforate", "diameter": "perforate.diameter", "pitch": "perforate.pitch", "margin": "perforate.margin"}),
|
||||
),
|
||||
(
|
||||
"sheet_fold",
|
||||
json!({"type": "sheet_fold", "a": o, "b": [1, 0, 0], "angle": "fold.angle", "radius": "fold.radius", "position": "centerline"}),
|
||||
),
|
||||
("sheet_unfold", json!({"type": "sheet_unfold"})),
|
||||
("sheet_convert", json!({"type": "sheet_convert", "body": "B", "face": o})),
|
||||
(
|
||||
"boss",
|
||||
json!({"type": "boss", "position": o, "diameter": "boss.diameter", "height": "boss.height", "hole_diameter": "boss.hole_diameter", "hole_depth": "boss.hole_depth",
|
||||
"draft": "boss.draft", "fillet": "boss.fillet", "ribs": "boss.ribs", "rib_thickness": "boss.rib_thickness", "rib_length": "boss.rib_length", "rib_offset": "boss.rib_offset"}),
|
||||
),
|
||||
("lip", json!({"type": "lip", "face": o, "width": "lip.width", "height": "lip.height", "gap": "lip.gap"})),
|
||||
(
|
||||
"snap_fit",
|
||||
json!({"type": "snap_fit", "position": o, "hook": [1, 0, 0], "length": "snap.length", "thickness": "snap.thickness", "width": "snap.width", "catch_depth": "snap.catch_depth", "catch_length": "snap.catch_length"}),
|
||||
),
|
||||
(
|
||||
"rest",
|
||||
json!({"type": "rest", "position": o, "width": "rest.width", "length": "rest.length", "height": "rest.height", "draft": "rest.draft", "thickness": "rest.thickness"}),
|
||||
),
|
||||
("boundary_fill", json!({"type": "boundary_fill", "tools": ["B"], "cells": [o]})),
|
||||
("remove", json!({"type": "remove", "bodies": ["B"]})),
|
||||
("patch", json!({"type": "patch"})),
|
||||
("stitch", json!({"type": "stitch", "bodies": ["B"], "tolerance": "stitch.tolerance"})),
|
||||
("thicken", json!({"type": "thicken", "bodies": ["B"], "thickness": "thicken.thickness", "operation": "new_body"})),
|
||||
("surface_extrude", json!({"type": "surface_extrude", "sketch": 1, "extent": extent("surface_extrude")})),
|
||||
(
|
||||
"surface_revolve",
|
||||
json!({"type": "surface_revolve", "sketch": 1, "axis": {"type": "world", "axis": "X"}, "angle": "srev.angle", "angle2": "srev.angle2"}),
|
||||
),
|
||||
("surface_sweep", json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]})),
|
||||
(
|
||||
"surface_sweep_options",
|
||||
json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
|
||||
"orientation": "parallel", "twist": "surface_sweep.twist", "scale": "surface_sweep.scale"}),
|
||||
),
|
||||
("surface_loft", json!({"type": "surface_loft", "sections": []})),
|
||||
("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": ""})),
|
||||
("mesh_convert", json!({"type": "mesh_convert", "body": "B", "tolerance": "mesh_convert.tolerance"})),
|
||||
("mesh_import", json!({"type": "mesh_import", "file": "a.stl", "meshes": []})),
|
||||
]
|
||||
}
|
||||
|
||||
/// The reference fields a fixture leaves out (they don't change its expressions), set here so
|
||||
/// every sketch, feature and body reference is exercised: sketches 1–8, feature `F`,
|
||||
/// construction plane `CP`, body `B`.
|
||||
pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
|
||||
let o = [0, 0, 0];
|
||||
let tool = json!({"body": "B", "point": o});
|
||||
let extra = match name {
|
||||
"extrude" | "extrude_thin" | "revolve" | "sweep" | "sweep_options" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
|
||||
"fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft"
|
||||
| "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_perforate" | "sheet_unfold" | "boss" | "lip"
|
||||
| "snap_fit" | "rest" | "surface_offset" => {
|
||||
json!({"body": "B"})
|
||||
}
|
||||
"combine" => json!({"target": "B", "tools": ["B"]}),
|
||||
"pattern_rectangular" | "pattern_circular" | "pattern_path" | "mirror" => json!({"bodies": ["B"]}),
|
||||
"hole_simple" | "hole_drilled" | "hole_counterbore" | "hole_countersink" => json!({"points": {"sketch": 3, "ids": ["p"]}}),
|
||||
"loft" => json!({"sections": [{"sketch": 4, "profiles": {"type": "all"}}, {"sketch": 5, "profiles": {"type": "all"}}], "targets": ["B"]}),
|
||||
"split" | "surface_trim" => json!({"tool": tool}),
|
||||
"surface_loft" => json!({"sections": [{"sketch": 4}, {"sketch": 5}]}),
|
||||
"split_face" => json!({"body": "B", "tool": tool, "sketch": 6}),
|
||||
"emboss" => json!({"face": {"body": "B", "at": o}, "targets": ["B"]}),
|
||||
"sheet_fold" => json!({"sketch": 7, "body": "B"}),
|
||||
"patch" => json!({"sketch": 8, "body": "B"}),
|
||||
_ => json!({}),
|
||||
};
|
||||
if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The positions and directions a fixture leaves out (they don't change its expressions or
|
||||
/// references), set here so every point, direction and axis the geometry walks read is present:
|
||||
/// `to` / `from` picks, line axes, normals and origins. `hole_simple` places its hole at
|
||||
/// `position` (no sketch points), the other holes at sketch points.
|
||||
pub(super) fn with_points(name: &str, mut v: Value) -> Value {
|
||||
let p = |i: f64| json!([i, i + 0.5, i + 0.25]);
|
||||
let line = json!({"type": "line", "origin": p(9.0), "dir": [0.0, 0.6, 0.8]});
|
||||
let extra = match name {
|
||||
"extrude" | "surface_extrude" => {
|
||||
if let Some(e) = v.get_mut("extent").and_then(Value::as_object_mut) {
|
||||
e.insert("to".into(), p(1.0));
|
||||
e.insert("from".into(), p(2.0));
|
||||
}
|
||||
json!({})
|
||||
}
|
||||
"revolve" => json!({"to": p(3.0), "axis": line}),
|
||||
"surface_revolve" => json!({"axis": line}),
|
||||
"hole_simple" => json!({"points": null, "to": p(4.0)}),
|
||||
"hole_drilled" | "hole_counterbore" | "hole_countersink" => json!({"to": p(4.0)}),
|
||||
"emboss" => json!({"face": {"body": "B", "at": p(5.0)}}),
|
||||
"sheet_convert" => json!({"bends": [p(6.0)]}),
|
||||
"sheet_fold" => json!({"fixed": p(7.0)}),
|
||||
"rest" => json!({"along": [0.6, 0.8, 0.0]}),
|
||||
"align" => json!({"from_normal": [0.0, 0.0, 1.0], "to_normal": [1.0, 0.0, 0.0]}),
|
||||
"scale" => json!({"origin": p(8.0)}),
|
||||
"move" => json!({"rotate_axis": [0.0, 1.0, 0.0]}),
|
||||
"unstitch" | "reverse_normal" => json!({"faces": [p(10.0)]}),
|
||||
_ => json!({}),
|
||||
};
|
||||
if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The paths of the leaves that differ between two JSON values.
|
||||
pub(super) fn changed(a: &Value, b: &Value, path: &str, out: &mut Vec<String>) {
|
||||
match (a, b) {
|
||||
(Value::Object(x), Value::Object(y)) => {
|
||||
for (k, v) in x {
|
||||
changed(v, y.get(k).unwrap_or(&Value::Null), &format!("{path}.{k}"), out);
|
||||
}
|
||||
}
|
||||
(Value::Array(x), Value::Array(y)) => {
|
||||
for (i, v) in x.iter().enumerate() {
|
||||
changed(v, y.get(i).unwrap_or(&Value::Null), &format!("{path}[{i}]"), out);
|
||||
}
|
||||
}
|
||||
_ if a != b => out.push(path.trim_start_matches('.').to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
body_transfer
|
||||
sketch
|
||||
inputs:
|
||||
Angle = sketch.angle (Angle)
|
||||
Offset = sketch.offset (Length)
|
||||
expressions: sketch.angle, sketch.offset
|
||||
sketch_along_path
|
||||
inputs:
|
||||
Distance (0 to 1) = sketch.t (Unitless)
|
||||
expressions: sketch.t
|
||||
extrude
|
||||
inputs:
|
||||
Distance = extrude.distance (Length)
|
||||
Distance 2 = extrude.distance2 (Length)
|
||||
Start offset = extrude.start_offset (Length)
|
||||
Taper angle = extrude.taper (Angle)
|
||||
To offset = extrude.to_offset (Length)
|
||||
expressions: extrude.distance, extrude.distance2, extrude.start_offset, extrude.taper, extrude.to_offset
|
||||
extrude_thin
|
||||
inputs:
|
||||
Distance = thin.distance (Length)
|
||||
Distance 2 = thin.distance2 (Length)
|
||||
Start offset = thin.start_offset (Length)
|
||||
Taper angle = thin.taper (Angle)
|
||||
To offset = thin.to_offset (Length)
|
||||
Thickness = thin.thickness (Length)
|
||||
Thickness 2 = thin.thickness2 (Length)
|
||||
expressions: thin.distance, thin.distance2, thin.start_offset, thin.taper, thin.to_offset, thin.thickness, thin.thickness2
|
||||
revolve
|
||||
inputs:
|
||||
Angle = revolve.angle (Angle)
|
||||
Angle 2 = revolve.angle2 (Angle)
|
||||
expressions: revolve.angle, revolve.angle2
|
||||
fillet_constant
|
||||
inputs:
|
||||
Radius = fillet.radius (Length)
|
||||
expressions: fillet.radius
|
||||
fillet_chord
|
||||
inputs:
|
||||
Chord length = fillet.radius (Length)
|
||||
expressions: fillet.radius
|
||||
fillet_curvature
|
||||
inputs:
|
||||
Equivalent radius = fillet.radius (Length)
|
||||
expressions: fillet.radius
|
||||
fillet_variable
|
||||
inputs:
|
||||
Start radius = fillet.radius (Length)
|
||||
End radius = fillet.radius2 (Length)
|
||||
Mid radius = fillet.mid (Length)
|
||||
expressions: fillet.radius, fillet.radius2, fillet.mid
|
||||
fillet_spline
|
||||
inputs:
|
||||
Start radius = fillet.radius (Length)
|
||||
Control radius = fillet.r1 (Length)
|
||||
Control radius = fillet.r2 (Length)
|
||||
End radius = fillet.r3 (Length)
|
||||
expressions: fillet.radius, fillet.r1, fillet.r2, fillet.r3
|
||||
chamfer
|
||||
inputs:
|
||||
Distance = chamfer.distance (Length)
|
||||
Distance 2 = chamfer.distance2 (Length)
|
||||
Angle = chamfer.angle (Angle)
|
||||
expressions: chamfer.distance, chamfer.distance2, chamfer.angle
|
||||
box
|
||||
inputs:
|
||||
Length = box.length (Length)
|
||||
Width = box.width (Length)
|
||||
Height = box.height (Length)
|
||||
expressions: box.length, box.width, box.height, box.x, box.y, box.z
|
||||
cylinder
|
||||
inputs:
|
||||
Radius = cyl.radius (Length)
|
||||
Height = cyl.height (Length)
|
||||
expressions: cyl.radius, cyl.height, cyl.x, cyl.y, cyl.z
|
||||
sphere
|
||||
inputs:
|
||||
Radius = sph.radius (Length)
|
||||
expressions: sph.radius, sph.x, sph.y, sph.z
|
||||
torus
|
||||
inputs:
|
||||
Major radius = tor.major (Length)
|
||||
Minor radius = tor.minor (Length)
|
||||
expressions: tor.major, tor.minor, tor.x, tor.y, tor.z
|
||||
combine
|
||||
pattern_rectangular
|
||||
inputs:
|
||||
Quantity = rect.count1 (Unitless)
|
||||
Spacing = rect.spacing1 (Length)
|
||||
Quantity 2 = rect.count2 (Unitless)
|
||||
Spacing 2 = rect.spacing2 (Length)
|
||||
expressions: rect.count1, rect.spacing1, rect.count2, rect.spacing2
|
||||
pattern_circular
|
||||
inputs:
|
||||
Quantity = circ.count (Unitless)
|
||||
Total angle = circ.angle (Angle)
|
||||
expressions: circ.count, circ.angle
|
||||
pattern_path
|
||||
inputs:
|
||||
Quantity = path.count (Unitless)
|
||||
Distance = path.spacing (Length)
|
||||
expressions: path.count, path.spacing
|
||||
mirror
|
||||
inputs:
|
||||
Angle = mirror.angle (Angle)
|
||||
Offset = mirror.offset (Length)
|
||||
expressions: mirror.angle, mirror.offset
|
||||
hole_simple
|
||||
inputs:
|
||||
Diameter = hole.diameter (Length)
|
||||
Depth = hole.depth (Length)
|
||||
expressions: hole.diameter, hole.depth
|
||||
hole_drilled
|
||||
inputs:
|
||||
Diameter = hole.diameter (Length)
|
||||
Depth = hole.depth (Length)
|
||||
Tip angle = hole.tip_angle (Angle)
|
||||
expressions: hole.diameter, hole.depth, hole.tip_angle
|
||||
hole_counterbore
|
||||
inputs:
|
||||
Diameter = hole.diameter (Length)
|
||||
Depth = hole.depth (Length)
|
||||
Counterbore diameter = hole.cb_diameter (Length)
|
||||
Counterbore depth = hole.cb_depth (Length)
|
||||
expressions: hole.diameter, hole.depth, hole.cb_diameter, hole.cb_depth
|
||||
hole_countersink
|
||||
inputs:
|
||||
Diameter = hole.diameter (Length)
|
||||
Depth = hole.depth (Length)
|
||||
Countersink diameter = hole.cs_diameter (Length)
|
||||
Countersink angle = hole.cs_angle (Angle)
|
||||
expressions: hole.diameter, hole.depth, hole.cs_diameter, hole.cs_angle
|
||||
thread
|
||||
inputs:
|
||||
Length = thread.length (Length)
|
||||
expressions: thread.length
|
||||
loft
|
||||
sweep
|
||||
sweep_options
|
||||
inputs:
|
||||
Twist = sweep.twist (Angle)
|
||||
Scale = sweep.scale (Unitless)
|
||||
expressions: sweep.twist, sweep.scale
|
||||
shell
|
||||
inputs:
|
||||
Thickness = shell.thickness (Length)
|
||||
expressions: shell.thickness
|
||||
draft
|
||||
inputs:
|
||||
Angle = draft.angle (Angle)
|
||||
Angle = draft_neutral.angle (Angle)
|
||||
Offset = draft_neutral.offset (Length)
|
||||
expressions: draft.angle, draft_neutral.angle, draft_neutral.offset
|
||||
construction_plane
|
||||
inputs:
|
||||
Angle = cplane.angle (Angle)
|
||||
Offset = cplane.offset (Length)
|
||||
expressions: cplane.angle, cplane.offset
|
||||
split
|
||||
inputs:
|
||||
Angle = split.angle (Angle)
|
||||
Offset = split.offset (Length)
|
||||
expressions: split.angle, split.offset
|
||||
construction_axis
|
||||
inputs:
|
||||
Angle = axis_a.angle (Angle)
|
||||
Offset = axis_a.offset (Length)
|
||||
Angle = axis_b.angle (Angle)
|
||||
Offset = axis_b.offset (Length)
|
||||
expressions: axis_a.angle, axis_a.offset, axis_b.angle, axis_b.offset
|
||||
construction_point_planes
|
||||
inputs:
|
||||
Angle = pt_a.angle (Angle)
|
||||
Offset = pt_a.offset (Length)
|
||||
Angle = pt_b.angle (Angle)
|
||||
Offset = pt_b.offset (Length)
|
||||
Angle = pt_c.angle (Angle)
|
||||
Offset = pt_c.offset (Length)
|
||||
expressions: pt_a.angle, pt_a.offset, pt_b.angle, pt_b.offset, pt_c.angle, pt_c.offset
|
||||
construction_point_along
|
||||
inputs:
|
||||
Distance (0 to 1) = pt.t (Unitless)
|
||||
expressions: pt.t
|
||||
split_face
|
||||
inputs:
|
||||
Angle = split_face.angle (Angle)
|
||||
Offset = split_face.offset (Length)
|
||||
expressions: split_face.angle, split_face.offset
|
||||
scale
|
||||
inputs:
|
||||
Scale factor = scale.factor (Unitless)
|
||||
X scale = scale.x (Unitless)
|
||||
Y scale = scale.y (Unitless)
|
||||
Z scale = scale.z (Unitless)
|
||||
expressions: scale.factor, scale.x, scale.y, scale.z
|
||||
offset_face
|
||||
inputs:
|
||||
Distance = offset_face.distance (Length)
|
||||
expressions: offset_face.distance
|
||||
delete_face
|
||||
bounding_solid
|
||||
inputs:
|
||||
Margin = bounding.margin (Length)
|
||||
expressions: bounding.margin
|
||||
pipe
|
||||
inputs:
|
||||
Diameter = pipe.diameter (Length)
|
||||
Wall = pipe.wall (Length)
|
||||
expressions: pipe.diameter, pipe.wall
|
||||
emboss
|
||||
inputs:
|
||||
Depth = emboss.depth (Length)
|
||||
expressions: emboss.depth
|
||||
rib
|
||||
inputs:
|
||||
Thickness = rib.thickness (Length)
|
||||
Depth = rib.depth (Length)
|
||||
expressions: rib.thickness, rib.depth
|
||||
replace_face
|
||||
inputs:
|
||||
Angle = replace_face.angle (Angle)
|
||||
Offset = replace_face.offset (Length)
|
||||
expressions: replace_face.angle, replace_face.offset
|
||||
align
|
||||
coil
|
||||
inputs:
|
||||
Diameter = coil.diameter (Length)
|
||||
Pitch = coil.pitch (Length)
|
||||
Revolutions = coil.turns (Unitless)
|
||||
Section size = coil.section_size (Length)
|
||||
Start angle = coil.start_angle (Angle)
|
||||
expressions: coil.diameter, coil.pitch, coil.turns, coil.section_size, coil.start_angle, coil.x, coil.y, coil.z
|
||||
sheet_base
|
||||
sheet_contour
|
||||
inputs:
|
||||
Distance = contour.distance (Length)
|
||||
expressions: contour.distance
|
||||
sheet_flange
|
||||
inputs:
|
||||
Height = flange.height (Length)
|
||||
Angle = flange.angle (Angle)
|
||||
Bend radius = flange.radius (Length)
|
||||
Edge angle = flange.e1_angle (Angle)
|
||||
Edge height = flange.e1_height (Length)
|
||||
Edge height = flange.e2_height (Length)
|
||||
expressions: flange.height, flange.angle, flange.radius, flange.e1_angle, flange.e1_height, flange.e2_height
|
||||
sheet_hem
|
||||
inputs:
|
||||
Length = hem.length (Length)
|
||||
Gap = hem.gap (Length)
|
||||
expressions: hem.length, hem.gap
|
||||
sheet_perforate
|
||||
inputs:
|
||||
Diameter = perforate.diameter (Length)
|
||||
Pitch = perforate.pitch (Length)
|
||||
Margin = perforate.margin (Length)
|
||||
expressions: perforate.diameter, perforate.pitch, perforate.margin
|
||||
sheet_fold
|
||||
inputs:
|
||||
Angle = fold.angle (Angle)
|
||||
Bend radius = fold.radius (Length)
|
||||
expressions: fold.angle, fold.radius
|
||||
sheet_unfold
|
||||
sheet_convert
|
||||
boss
|
||||
inputs:
|
||||
Diameter = boss.diameter (Length)
|
||||
Height = boss.height (Length)
|
||||
Hole diameter = boss.hole_diameter (Length)
|
||||
Hole depth = boss.hole_depth (Length)
|
||||
Draft angle = boss.draft (Angle)
|
||||
Root fillet = boss.fillet (Length)
|
||||
Ribs = boss.ribs (Unitless)
|
||||
Rib thickness = boss.rib_thickness (Length)
|
||||
Rib length = boss.rib_length (Length)
|
||||
Rib offset = boss.rib_offset (Length)
|
||||
expressions: boss.diameter, boss.height, boss.hole_diameter, boss.hole_depth, boss.draft, boss.fillet, boss.ribs, boss.rib_thickness, boss.rib_length, boss.rib_offset
|
||||
lip
|
||||
inputs:
|
||||
Width = lip.width (Length)
|
||||
Height = lip.height (Length)
|
||||
Gap = lip.gap (Length)
|
||||
expressions: lip.width, lip.height, lip.gap
|
||||
snap_fit
|
||||
inputs:
|
||||
Length = snap.length (Length)
|
||||
Thickness = snap.thickness (Length)
|
||||
Width = snap.width (Length)
|
||||
Catch depth = snap.catch_depth (Length)
|
||||
Catch length = snap.catch_length (Length)
|
||||
expressions: snap.length, snap.thickness, snap.width, snap.catch_depth, snap.catch_length
|
||||
rest
|
||||
inputs:
|
||||
Width = rest.width (Length)
|
||||
Height = rest.height (Length)
|
||||
Length = rest.length (Length)
|
||||
Draft angle = rest.draft (Angle)
|
||||
Wall thickness = rest.thickness (Length)
|
||||
expressions: rest.width, rest.height, rest.length, rest.draft, rest.thickness
|
||||
boundary_fill
|
||||
remove
|
||||
patch
|
||||
stitch
|
||||
inputs:
|
||||
Tolerance = stitch.tolerance (Length)
|
||||
expressions: stitch.tolerance
|
||||
thicken
|
||||
inputs:
|
||||
Thickness = thicken.thickness (Length)
|
||||
expressions: thicken.thickness
|
||||
surface_extrude
|
||||
inputs:
|
||||
Distance = surface_extrude.distance (Length)
|
||||
Distance 2 = surface_extrude.distance2 (Length)
|
||||
Start offset = surface_extrude.start_offset (Length)
|
||||
Taper angle = surface_extrude.taper (Angle)
|
||||
To offset = surface_extrude.to_offset (Length)
|
||||
expressions: surface_extrude.distance, surface_extrude.distance2, surface_extrude.start_offset, surface_extrude.taper, surface_extrude.to_offset
|
||||
surface_revolve
|
||||
inputs:
|
||||
Angle = srev.angle (Angle)
|
||||
Angle 2 = srev.angle2 (Angle)
|
||||
expressions: srev.angle, srev.angle2
|
||||
surface_sweep
|
||||
surface_sweep_options
|
||||
inputs:
|
||||
Twist = surface_sweep.twist (Angle)
|
||||
Scale = surface_sweep.scale (Unitless)
|
||||
expressions: surface_sweep.twist, surface_sweep.scale
|
||||
surface_loft
|
||||
surface_offset
|
||||
inputs:
|
||||
Distance = soff.distance (Length)
|
||||
Tolerance = soff.tolerance (Length)
|
||||
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)
|
||||
Offset = trim.offset (Length)
|
||||
expressions: trim.angle, trim.offset
|
||||
move
|
||||
inputs:
|
||||
X distance = move.x (Length)
|
||||
Y distance = move.y (Length)
|
||||
Z distance = move.z (Length)
|
||||
Angle = move.angle (Angle)
|
||||
expressions: move.x, move.y, move.z, move.angle
|
||||
import
|
||||
mesh_convert
|
||||
inputs:
|
||||
Tolerance = mesh_convert.tolerance (Length)
|
||||
expressions: mesh_convert.tolerance
|
||||
mesh_import
|
||||
@@ -0,0 +1,175 @@
|
||||
body_transfer
|
||||
sketch
|
||||
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
|
||||
sketch_along_path
|
||||
moves: plane.path.at[0], plane.path.at[1], plane.path.at[2]
|
||||
extrude
|
||||
moves: extent.from[0], extent.from[1], extent.from[2], extent.to[0], extent.to[1], extent.to[2]
|
||||
face points: [1, 1.5, 1.25], [2, 2.5, 2.25]
|
||||
profiles: 1 all
|
||||
extrude_thin
|
||||
revolve
|
||||
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2], to[0], to[1], to[2]
|
||||
face points: [3, 3.5, 3.25]
|
||||
profiles: 1 all
|
||||
fillet_constant
|
||||
moves: edges[0][0], edges[0][1], edges[0][2]
|
||||
fillet_chord
|
||||
moves: edges[0][0], edges[0][1], edges[0][2]
|
||||
fillet_curvature
|
||||
moves: edges[0][0], edges[0][1], edges[0][2]
|
||||
fillet_variable
|
||||
moves: edges[0][0], edges[0][1], edges[0][2], style.start[0], style.start[1], style.start[2]
|
||||
fillet_spline
|
||||
moves: edges[0][0], edges[0][1], edges[0][2], style.start[0], style.start[1], style.start[2]
|
||||
chamfer
|
||||
moves: edges[0][0], edges[0][1], edges[0][2]
|
||||
box
|
||||
moves: corner[0], corner[1], corner[2]
|
||||
placement: box.x, box.y, box.z
|
||||
cylinder
|
||||
moves: axis[0], axis[2], base[0], base[1], base[2]
|
||||
placement: cyl.x, cyl.y, cyl.z
|
||||
sphere
|
||||
moves: center[0], center[1], center[2]
|
||||
placement: sph.x, sph.y, sph.z
|
||||
torus
|
||||
moves: center[0], center[1], center[2]
|
||||
placement: tor.x, tor.y, tor.z
|
||||
combine
|
||||
pattern_rectangular
|
||||
moves: pattern.dir1[0], pattern.dir1[1], pattern.dir2[1], pattern.dir2[2]
|
||||
pattern_circular
|
||||
moves: pattern.axis[0], pattern.axis[2], pattern.origin[0], pattern.origin[1], pattern.origin[2]
|
||||
pattern_path
|
||||
mirror
|
||||
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
|
||||
hole_simple
|
||||
moves: direction[0], direction[2], position[0], position[1], position[2], to[0], to[1], to[2]
|
||||
face points: [0, 0, 0], [4, 4.5, 4.25]
|
||||
hole_drilled
|
||||
moves: direction[0], direction[2], position[0], position[1], position[2], to[0], to[1], to[2]
|
||||
face points: [4, 4.5, 4.25]
|
||||
hole_counterbore
|
||||
moves: direction[0], direction[2], position[0], position[1], position[2], to[0], to[1], to[2]
|
||||
face points: [4, 4.5, 4.25]
|
||||
hole_countersink
|
||||
moves: direction[0], direction[2], position[0], position[1], position[2], to[0], to[1], to[2]
|
||||
face points: [4, 4.5, 4.25]
|
||||
thread
|
||||
moves: face[0], face[1], face[2]
|
||||
face points: [0, 0, 0]
|
||||
loft
|
||||
profiles: 4 all, 5 all
|
||||
sweep
|
||||
profiles: 1 all
|
||||
sweep_options
|
||||
moves: orientation.direction[1], orientation.direction[2]
|
||||
profiles: 1 all
|
||||
shell
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
draft
|
||||
moves: faces[0][0], faces[0][1], faces[0][2], neutral.axis_dir[0], neutral.axis_dir[1], neutral.axis_origin[0], neutral.axis_origin[1], neutral.axis_origin[2], pull[0], pull[2]
|
||||
face points: [0, 0, 0]
|
||||
construction_plane
|
||||
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
|
||||
split
|
||||
moves: 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]
|
||||
construction_axis
|
||||
moves: def.a.plane.axis_dir[0], def.a.plane.axis_dir[1], def.a.plane.axis_origin[0], def.a.plane.axis_origin[1], def.a.plane.axis_origin[2], def.b.plane.axis_dir[0], def.b.plane.axis_dir[1], def.b.plane.axis_origin[0], def.b.plane.axis_origin[1], def.b.plane.axis_origin[2]
|
||||
construction_point_planes
|
||||
moves: def.a.plane.axis_dir[0], def.a.plane.axis_dir[1], def.a.plane.axis_origin[0], def.a.plane.axis_origin[1], def.a.plane.axis_origin[2], def.b.plane.axis_dir[0], def.b.plane.axis_dir[1], def.b.plane.axis_origin[0], def.b.plane.axis_origin[1], def.b.plane.axis_origin[2], def.c.plane.axis_dir[0], def.c.plane.axis_dir[1], def.c.plane.axis_origin[0], def.c.plane.axis_origin[1], def.c.plane.axis_origin[2]
|
||||
construction_point_along
|
||||
moves: def.path.at[0], def.path.at[1], def.path.at[2]
|
||||
split_face
|
||||
moves: faces[0][0], faces[0][1], faces[0][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]
|
||||
face points: [0, 0, 0]
|
||||
scale
|
||||
moves: origin[0], origin[1], origin[2]
|
||||
offset_face
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
delete_face
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
bounding_solid
|
||||
pipe
|
||||
emboss
|
||||
face points: [5, 5.5, 5.25]
|
||||
profiles: 1 all
|
||||
rib
|
||||
replace_face
|
||||
moves: faces[0][0], faces[0][1], faces[0][2], target.axis_dir[0], target.axis_dir[1], target.axis_origin[0], target.axis_origin[1], target.axis_origin[2]
|
||||
face points: [0, 0, 0]
|
||||
align
|
||||
moves: from[0], from[1], from[2], from_normal[0], from_normal[2], to[0], to[1], to[2], to_normal[0], to_normal[1]
|
||||
coil
|
||||
moves: axis[0], axis[2], base[0], base[1], base[2]
|
||||
placement: coil.x, coil.y, coil.z
|
||||
sheet_base
|
||||
profiles: 1 all
|
||||
sheet_contour
|
||||
sheet_flange
|
||||
moves: edges[0].point[0], edges[0].point[1], edges[0].point[2], edges[1].point[0], edges[1].point[1], edges[1].point[2]
|
||||
sheet_hem
|
||||
moves: edges[0][0], edges[0][1], edges[0][2]
|
||||
sheet_perforate
|
||||
sheet_fold
|
||||
moves: a[0], a[1], a[2], b[1], b[2], fixed[0], fixed[1], fixed[2]
|
||||
sheet_unfold
|
||||
sheet_convert
|
||||
moves: bends[0][0], bends[0][1], bends[0][2], face[0], face[1], face[2]
|
||||
face points: [0, 0, 0]
|
||||
boss
|
||||
moves: direction[0], direction[2], position[0], position[1], position[2]
|
||||
face points: [0, 0, 0]
|
||||
lip
|
||||
moves: face[0], face[1], face[2]
|
||||
snap_fit
|
||||
moves: direction[0], direction[2], hook[0], hook[1], position[0], position[1], position[2]
|
||||
face points: [0, 0, 0]
|
||||
rest
|
||||
moves: along[0], along[1], along[2], direction[0], direction[2], position[0], position[1], position[2]
|
||||
face points: [0, 0, 0]
|
||||
boundary_fill
|
||||
moves: cells[0][0], cells[0][1], cells[0][2]
|
||||
remove
|
||||
patch
|
||||
profiles: 8 all
|
||||
stitch
|
||||
thicken
|
||||
surface_extrude
|
||||
moves: extent.from[0], extent.from[1], extent.from[2], extent.to[0], extent.to[1], extent.to[2]
|
||||
face points: [1, 1.5, 1.25], [2, 2.5, 2.25]
|
||||
surface_revolve
|
||||
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2]
|
||||
surface_sweep
|
||||
surface_sweep_options
|
||||
surface_loft
|
||||
surface_offset
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
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
|
||||
moves: rotate_axis[1], rotate_axis[2]
|
||||
import
|
||||
mesh_convert
|
||||
mesh_import
|
||||
@@ -0,0 +1,197 @@
|
||||
body_transfer
|
||||
remap_bodies: bodies[0]
|
||||
sketch
|
||||
remap_refs: plane.base.base.name
|
||||
sketch_along_path
|
||||
extrude
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
remap_bodies: targets[0]
|
||||
extrude_thin
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
remap_bodies: targets[0]
|
||||
revolve
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
remap_bodies: targets[0]
|
||||
fillet_constant
|
||||
remap_bodies: body
|
||||
fillet_chord
|
||||
remap_bodies: body
|
||||
fillet_curvature
|
||||
remap_bodies: body
|
||||
fillet_variable
|
||||
remap_bodies: body
|
||||
fillet_spline
|
||||
remap_bodies: body
|
||||
chamfer
|
||||
remap_bodies: body
|
||||
box
|
||||
cylinder
|
||||
sphere
|
||||
torus
|
||||
combine
|
||||
remap_bodies: target, tools[0]
|
||||
pattern_rectangular
|
||||
remap_refs: features[0]
|
||||
remap_bodies: bodies[0]
|
||||
pattern_circular
|
||||
remap_refs: features[0]
|
||||
remap_bodies: bodies[0]
|
||||
pattern_path
|
||||
sketches: [1]
|
||||
remap_refs: features[0], pattern.path_sketch
|
||||
remap_bodies: bodies[0]
|
||||
mirror
|
||||
remap_refs: features[0], plane.base.base.name
|
||||
remap_bodies: bodies[0]
|
||||
hole_simple
|
||||
sketches: [3]
|
||||
remap_refs: points.sketch
|
||||
hole_drilled
|
||||
sketches: [3]
|
||||
remap_refs: points.sketch
|
||||
hole_counterbore
|
||||
sketches: [3]
|
||||
remap_refs: points.sketch
|
||||
hole_countersink
|
||||
sketches: [3]
|
||||
remap_refs: points.sketch
|
||||
thread
|
||||
loft
|
||||
sketches: [4, 5]
|
||||
remap_refs: sections[0].sketch, sections[1].sketch
|
||||
remap_bodies: targets[0]
|
||||
sweep
|
||||
sketches: [1, 2]
|
||||
remap_refs: path_sketch, sketch
|
||||
remap_bodies: targets[0]
|
||||
sweep_options
|
||||
sketches: [1, 2]
|
||||
remap_refs: path_sketch, sketch
|
||||
remap_bodies: targets[0]
|
||||
shell
|
||||
remap_bodies: body
|
||||
draft
|
||||
remap_refs: neutral.base.base.name
|
||||
remap_bodies: body
|
||||
construction_plane
|
||||
remap_refs: plane.base.base.name
|
||||
split
|
||||
remap_refs: plane.base.base.name
|
||||
remap_bodies: body, tool.body
|
||||
construction_axis
|
||||
construction_point_planes
|
||||
construction_point_along
|
||||
split_face
|
||||
sketches: [6]
|
||||
remap_refs: plane.base.base.name, sketch
|
||||
remap_bodies: body, tool.body
|
||||
scale
|
||||
remap_bodies: bodies[0]
|
||||
offset_face
|
||||
remap_bodies: body
|
||||
delete_face
|
||||
remap_bodies: body
|
||||
bounding_solid
|
||||
remap_bodies: bodies[0]
|
||||
pipe
|
||||
sketches: [1]
|
||||
remap_refs: path_sketch
|
||||
remap_bodies: targets[0]
|
||||
emboss
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
remap_bodies: face.body, targets[0]
|
||||
rib
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
replace_face
|
||||
remap_refs: target.base.base.name
|
||||
remap_bodies: body
|
||||
align
|
||||
remap_bodies: bodies[0]
|
||||
coil
|
||||
remap_bodies: targets[0]
|
||||
sheet_base
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
sheet_contour
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
sheet_flange
|
||||
remap_bodies: body
|
||||
sheet_hem
|
||||
remap_bodies: body
|
||||
sheet_perforate
|
||||
remap_bodies: body
|
||||
sheet_fold
|
||||
sketches: [7]
|
||||
remap_refs: sketch
|
||||
remap_bodies: body
|
||||
sheet_unfold
|
||||
remap_bodies: body
|
||||
sheet_convert
|
||||
remap_bodies: body
|
||||
boss
|
||||
remap_bodies: body
|
||||
lip
|
||||
remap_bodies: body
|
||||
snap_fit
|
||||
remap_bodies: body
|
||||
rest
|
||||
remap_bodies: body
|
||||
boundary_fill
|
||||
remap_bodies: tools[0]
|
||||
remove
|
||||
remap_bodies: bodies[0]
|
||||
patch
|
||||
sketches: [8]
|
||||
remap_refs: sketch
|
||||
remap_bodies: body
|
||||
stitch
|
||||
remap_bodies: bodies[0]
|
||||
thicken
|
||||
remap_bodies: bodies[0], targets[0]
|
||||
surface_extrude
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
surface_revolve
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
surface_sweep
|
||||
sketches: [1, 2]
|
||||
remap_refs: path_sketch, sketch
|
||||
surface_sweep_options
|
||||
sketches: [1, 2]
|
||||
remap_refs: path_sketch, sketch
|
||||
surface_loft
|
||||
sketches: [4, 5]
|
||||
remap_refs: sections[0].sketch, sections[1].sketch
|
||||
surface_offset
|
||||
remap_bodies: body
|
||||
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
|
||||
move
|
||||
remap_bodies: bodies[0]
|
||||
import
|
||||
mesh_convert
|
||||
remap_bodies: body
|
||||
mesh_import
|
||||
+205
-3
@@ -138,13 +138,75 @@
|
||||
//! Under the same version, a fillet's style can be `curvature` (a G2 quintic blend; `radius`
|
||||
//! is the equivalent circular setback). Fillets of the other styles save as before; 0.9.0
|
||||
//! would refuse the unknown style.
|
||||
//! - 20: an Extrude can be thin (`thin`: `curves`, `thickness`, optional `side` and
|
||||
//! `thickness2`): a wall along sketch curves instead of its profiles. Version 19 designs need
|
||||
//! no transformation, and an extrude without `thin` saves as before; the version stamp makes
|
||||
//! 0.10.0 (which reads up to 19) ask for an update instead of ignoring the wall and filling
|
||||
//! the sketch's profiles.
|
||||
//! A `sheet_perforate` feature (Perforate: `diameter`, `pitch`, optional `margin`,
|
||||
//! `pattern` and `body`): a perforation field stored on a sheet, for the flat pattern, the DXF
|
||||
//! and the measure. It is also first saved in 20; 0.10.0 asks for an update instead of
|
||||
//! meeting a feature it can't read.
|
||||
//! Associative, editable sketch patterns (`patterns` in a sketch) and their point/radius
|
||||
//! relations are also first saved in 20. Old sketch copies stay independent: migration cannot
|
||||
//! infer which curves formed a pattern.
|
||||
//! 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.
|
||||
//! Also under 21, a Sweep and a Surface Sweep can carry `orientation` (`parallel`, or
|
||||
//! `{direction: [x, y, z]}`; absent: perpendicular), `twist` (an angle expression) and `scale`
|
||||
//! (the end scale, an expression). A sweep without them saves as before; 0.11.x refuses
|
||||
//! format 21, so no released build sweeps a twisted or scaled profile straight.
|
||||
//! Nothing new is saved for converted sheets' corners and bend ends, but a design converts as
|
||||
//! the rule says when it is evaluated: corners between the walls take the rule's corner relief
|
||||
//! and a bend ending beside more of the fixed face its bend relief (0.11.0 left every corner a
|
||||
//! notch and cut no bend relief), and a wall on part of an edge bends only where it stands
|
||||
//! (0.11.0 refused it).
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 19;
|
||||
pub const FORMAT: u32 = 21;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solidcraft/{n}")
|
||||
@@ -161,7 +223,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; 18] = [
|
||||
const JSON_STEPS: [JsonStep; 20] = [
|
||||
|_| Ok(()),
|
||||
v2_to_v3,
|
||||
v3_to_v4,
|
||||
@@ -180,6 +242,8 @@ const JSON_STEPS: [JsonStep; 18] = [
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
];
|
||||
|
||||
/// Stored face and edge names were worked out from geometry before format 14; they get the
|
||||
@@ -378,7 +442,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)?;
|
||||
@@ -455,11 +519,18 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name)));
|
||||
}
|
||||
}
|
||||
if !all_unique(d.features.iter().map(|f| f.id))
|
||||
|| !all_unique(d.components.iter().map(|c| c.id))
|
||||
|| !all_unique(d.occurrences.iter().map(|o| o.id))
|
||||
{
|
||||
return Err(DocError::Invalid("damaged design: duplicate feature, component or occurrence id".into()));
|
||||
}
|
||||
let component_ids: std::collections::BTreeSet<_> = d.components.iter().map(|component| component.id).chain(std::iter::once(0)).collect();
|
||||
for feature in &d.features {
|
||||
if let crate::FeatureKind::Sketch { sketch, .. } = &feature.kind {
|
||||
sketch.validate_freeform_offsets().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
sketch.validate_wires().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
sketch.validate_patterns().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
sketch.validate_periodic_splines().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
}
|
||||
if let crate::FeatureKind::BodyTransfer { from, to, .. } = &feature.kind
|
||||
@@ -476,10 +547,21 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
// Fill in what older designs (and hand-edited files) may lack; a no-op otherwise.
|
||||
crate::format_offsets::validate_typed(&d)?;
|
||||
normalise(&mut d)?;
|
||||
// `next_id` is trusted by nothing: a hand-edited or generated file may omit it or leave it stale,
|
||||
// and the next feature, component or occurrence would then reuse an id that is already taken.
|
||||
let largest =
|
||||
d.features.iter().map(|f| f.id).chain(d.components.iter().map(|c| c.id)).chain(d.occurrences.iter().map(|o| o.id)).max().unwrap_or(0);
|
||||
let floor = largest.checked_add(1).ok_or_else(|| DocError::Invalid("damaged design: the id space is exhausted".into()))?;
|
||||
d.next_id = d.next_id.max(floor).max(1);
|
||||
d.format = format_string(FORMAT);
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn all_unique(ids: impl Iterator<Item = u64>) -> bool {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
ids.into_iter().all(|id| seen.insert(id))
|
||||
}
|
||||
|
||||
/// The caps interactive editing enforces, applied to the raw JSON so an oversized sketch is
|
||||
/// never deserialised. Every sketch in the timeline is solved when the design opens, and the
|
||||
/// solver sizes its dense matrices by the sketch, so a file with hundreds of thousands of
|
||||
@@ -496,9 +578,34 @@ fn validate_sketch_sizes(value: &Value) -> Result<()> {
|
||||
if len("points") > solidcraft_sketch::MAX_POINTS
|
||||
|| len("curves") > solidcraft_sketch::MAX_CURVES
|
||||
|| len("constraints") > solidcraft_sketch::MAX_CONSTRAINTS
|
||||
|| len("patterns") > solidcraft_sketch::MAX_CONSTRAINTS
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
if let Some(patterns) = sketch.get("patterns").and_then(Value::as_array) {
|
||||
let mut points = 0usize;
|
||||
let mut curves = 0usize;
|
||||
let mut constraints = 0usize;
|
||||
for pattern in patterns {
|
||||
let Some(entities) = pattern.get("entities").and_then(Value::as_array) else { return Err(invalid()) };
|
||||
let Some(instances) = pattern.get("instances").and_then(Value::as_array) else { return Err(invalid()) };
|
||||
if entities.len() > solidcraft_sketch::MAX_CURVES + solidcraft_sketch::MAX_POINTS || instances.len() >= 500 {
|
||||
return Err(invalid());
|
||||
}
|
||||
for instance in instances {
|
||||
let size = |key| instance.get(key).and_then(Value::as_array).map_or(0, Vec::len);
|
||||
points = points.saturating_add(size("points"));
|
||||
curves = curves.saturating_add(size("curves"));
|
||||
constraints = constraints.saturating_add(size("constraints"));
|
||||
if points > solidcraft_sketch::MAX_POINTS
|
||||
|| curves > solidcraft_sketch::MAX_CURVES
|
||||
|| constraints > solidcraft_sketch::MAX_CONSTRAINTS
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -804,6 +911,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<()> {
|
||||
@@ -1031,9 +1148,11 @@ mod tests {
|
||||
document
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch,
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "10".into(),
|
||||
direction: Direction::Negative,
|
||||
distance2: None,
|
||||
@@ -1594,4 +1713,87 @@ mod tests {
|
||||
assert!(error.contains("sketch too large"), "{key}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn two_components_one_occurrence() -> Document {
|
||||
let mut d = Document::new("ids");
|
||||
let a = d.add_component(None, 0).unwrap();
|
||||
d.add_component(None, 0).unwrap();
|
||||
d.add_occurrence(a, 0, crate::IDENTITY).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn edited(d: &Document, f: impl FnOnce(&mut Value)) -> String {
|
||||
let mut v: Value = serde_json::from_str(&d.to_json()).unwrap();
|
||||
f(&mut v);
|
||||
v.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_stale_next_id_does_not_reuse_existing_ids() {
|
||||
let d = two_components_one_occurrence();
|
||||
let largest = d.components.iter().map(|c| c.id).chain(d.occurrences.iter().map(|o| o.id)).max().unwrap();
|
||||
for stale in [None, Some(0), Some(1)] {
|
||||
let json = edited(&d, |v| {
|
||||
let o = v.as_object_mut().unwrap();
|
||||
match stale {
|
||||
Some(n) => o.insert("next_id".into(), n.into()),
|
||||
None => o.remove("next_id"),
|
||||
};
|
||||
});
|
||||
let mut back = read(&json).unwrap();
|
||||
assert!(back.next_id > largest, "next_id {} after load with {stale:?}", back.next_id);
|
||||
let fresh = back.add_component(None, 0).unwrap();
|
||||
assert!(fresh > largest, "new component reused id {fresh}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_component_or_occurrence_ids_are_a_damaged_design() {
|
||||
let d = two_components_one_occurrence();
|
||||
let dup_component = edited(&d, |v| v["components"][1]["id"] = v["components"][0]["id"].clone());
|
||||
let dup_occurrence = edited(&d, |v| {
|
||||
let first = v["occurrences"][0].clone();
|
||||
v["occurrences"].as_array_mut().unwrap().push(first);
|
||||
});
|
||||
for json in [dup_component, dup_occurrence] {
|
||||
let error = read(&json).unwrap_err().to_string();
|
||||
assert!(error.contains("damaged design"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_id_space_is_an_error_not_a_panic() {
|
||||
let d = two_components_one_occurrence();
|
||||
let json = edited(&d, |v| v["next_id"] = u64::MAX.into());
|
||||
let mut back = read(&json).unwrap();
|
||||
let before = back.components.len();
|
||||
assert!(back.add_component(None, 0).is_err());
|
||||
assert_eq!(back.components.len(), before);
|
||||
let json = edited(&d, |v| v["components"][1]["id"] = u64::MAX.into());
|
||||
assert!(read(&json).unwrap_err().to_string().contains("damaged design"));
|
||||
}
|
||||
|
||||
/// A sweep saved before its options existed loads as a plain sweep and saves as it was; the
|
||||
/// options round-trip.
|
||||
#[test]
|
||||
fn sweep_options_are_optional_and_round_trip() {
|
||||
use crate::{FeatureKind, SweepOrientation};
|
||||
let old =
|
||||
serde_json::json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"], "operation": "new_body"});
|
||||
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
|
||||
let FeatureKind::Sweep { orientation, twist, scale, .. } = &k else { panic!("{k:?}") };
|
||||
assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none());
|
||||
assert_eq!(serde_json::to_value(&k).unwrap(), old);
|
||||
let old = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]});
|
||||
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&k).unwrap(), old);
|
||||
let new = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
|
||||
"orientation": {"direction": [0.0, 1.0, 0.0]}, "twist": "90 deg", "scale": "0.5"});
|
||||
let k: FeatureKind = serde_json::from_value(new.clone()).unwrap();
|
||||
let FeatureKind::SurfaceSweep { orientation, .. } = &k else { panic!("{k:?}") };
|
||||
assert_eq!(*orientation, SweepOrientation::Direction(solidcraft_geom::Vec3::Y));
|
||||
assert_eq!(serde_json::to_value(&k).unwrap(), new);
|
||||
let parallel: SweepOrientation = serde_json::from_value(serde_json::json!("parallel")).unwrap();
|
||||
assert_eq!(parallel, SweepOrientation::Parallel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,9 +423,11 @@ mod tests {
|
||||
document
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
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,
|
||||
|
||||
+754
-46
@@ -7,14 +7,17 @@
|
||||
//! `T_B · F_b = T_A · F_a · M(q) · Flip`, so `T_B = T_A · F_a · M(q) · Flip · F_b⁻¹`, where `T`
|
||||
//! are occurrence world transforms, `F` the joint frames in each component's own frame, and `q`
|
||||
//! the joint's values (angles in radians, distances in mm). The solver walks the graph from
|
||||
//! grounded occurrences (and the root, which never moves); joints that close a loop are checked
|
||||
//! and reported when they disagree.
|
||||
//! grounded occurrences (and the root, which never moves). Joints that close a loop (a four-bar
|
||||
//! linkage, a slider-crank) are met by moving the values of the other joints around the loop;
|
||||
//! those that still disagree are reported.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use solidcraft_sketch::linalg;
|
||||
|
||||
use crate::{DocError, Document, IDENTITY, Mat, ModelState, Result, apply_point, apply_vector, mat_inverse, mat_mul};
|
||||
|
||||
/// Joints the solver (and the URDF export) reads at most.
|
||||
@@ -320,6 +323,9 @@ pub struct Solution {
|
||||
/// New world transforms of occurrences the joints place.
|
||||
#[serde(skip)]
|
||||
pub world: BTreeMap<u64, Mat>,
|
||||
/// New values of the joints that moved to close loops (joint id → values).
|
||||
#[serde(skip)]
|
||||
pub values: BTreeMap<u64, Vec<f64>>,
|
||||
/// Joints that disagree with the placement found through others: (joint id, name, why).
|
||||
pub conflicts: Vec<(u64, String, String)>,
|
||||
/// Joints that could not be applied (bad origin…): (joint id, name, why).
|
||||
@@ -330,21 +336,60 @@ pub struct Solution {
|
||||
pub fixed: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Joint values by joint id.
|
||||
type Values = BTreeMap<u64, Vec<f64>>;
|
||||
|
||||
/// How far two placements differ: (translation mm, rotation rad).
|
||||
fn mismatch(a: &Mat, b: &Mat) -> (f64, f64) {
|
||||
let t = Vec3::new(a[3][0] - b[3][0], a[3][1] - b[3][1], a[3][2] - b[3][2]).len();
|
||||
let mut tr = 0.0;
|
||||
for i in 0..3 {
|
||||
for k in 0..3 {
|
||||
tr += a[i][k] * b[i][k];
|
||||
// The turn between them from both its cosine and its sine: the cosine alone loses small
|
||||
// angles to rounding.
|
||||
let c = |i: usize, k: usize| (0..3).map(|n| a[i][n] * b[k][n]).sum::<f64>();
|
||||
let sin2 = Vec3::new(c(1, 2) - c(2, 1), c(2, 0) - c(0, 2), c(0, 1) - c(1, 0)).len();
|
||||
(t, sin2.atan2(c(0, 0) + c(1, 1) + c(2, 2) - 1.0))
|
||||
}
|
||||
|
||||
/// 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, 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) {
|
||||
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 = next;
|
||||
}
|
||||
}
|
||||
if linked(vals) == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(t, ((tr - 1.0) / 2.0).clamp(-1.0, 1.0).acos())
|
||||
}
|
||||
|
||||
/// Effective values of every joint after motion links and limits.
|
||||
pub fn joint_values(asm: &Assembly) -> BTreeMap<u64, Vec<f64>> {
|
||||
let mut vals: BTreeMap<u64, Vec<f64>> = asm
|
||||
let mut vals: Values = asm
|
||||
.joints
|
||||
.iter()
|
||||
.map(|j| {
|
||||
@@ -353,15 +398,7 @@ pub fn joint_values(asm: &Assembly) -> BTreeMap<u64, Vec<f64>> {
|
||||
(j.id, q)
|
||||
})
|
||||
.collect();
|
||||
// Links in order, a few passes for chains.
|
||||
for _ in 0..4 {
|
||||
for l in &asm.links {
|
||||
let Some(src) = vals.get(&l.a).and_then(|q| q.get(l.ia)).copied() else { continue };
|
||||
if let Some(v) = vals.get_mut(&l.b).and_then(|q| q.get_mut(l.ib)) {
|
||||
*v = l.ratio * src + l.offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
apply_links(asm, &mut vals);
|
||||
for j in &asm.joints {
|
||||
if let Some(q) = vals.get_mut(&j.id) {
|
||||
clamp(j, q);
|
||||
@@ -370,12 +407,621 @@ pub fn joint_values(asm: &Assembly) -> BTreeMap<u64, Vec<f64>> {
|
||||
vals
|
||||
}
|
||||
|
||||
/// The turn and offset from A's joint frame to B's: mated (B's z against A's, as faces meet)
|
||||
/// unless flipped.
|
||||
fn flip_of(j: &Joint) -> Mat {
|
||||
let mut m = rot_z(j.angle);
|
||||
m[3][2] = j.offset;
|
||||
if !j.flip { mat_mul(&m, &[[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } else { m }
|
||||
}
|
||||
|
||||
/// B's world transform from A's (or A's from B's, going the other way) for the joint's values.
|
||||
fn through(j: &Joint, (fa, fb): &(Mat, Mat), q: &[f64], from_a: bool, t: &Mat) -> Option<Mat> {
|
||||
let link = mat_mul(&mat_mul(&motion(j.kind, q), &flip_of(j)), &mat_inverse(fb)?);
|
||||
if from_a {
|
||||
Some(mat_mul(&mat_mul(t, fa), &link))
|
||||
} else {
|
||||
// T_A = T_B · link⁻¹ · F_a⁻¹
|
||||
Some(mat_mul(&mat_mul(t, &mat_inverse(&link)?), &mat_inverse(fa)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// The spanning tree the solver places occurrences along, out from the fixed ones.
|
||||
struct Tree<'a> {
|
||||
joints: BTreeMap<u64, &'a Joint>,
|
||||
/// Joint frames in component coordinates (A's, B's).
|
||||
frames: BTreeMap<u64, (Mat, Mat)>,
|
||||
fixed: BTreeMap<u64, Mat>,
|
||||
/// Placed occurrence → (the occurrence it is placed from, the joint, from the joint's A side).
|
||||
parent: BTreeMap<u64, (u64, u64, bool)>,
|
||||
/// Placed occurrences in placement order.
|
||||
order: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Tree<'_> {
|
||||
fn step(&self, joint: u64, from_a: bool, t: &Mat, vals: &Values) -> Option<Mat> {
|
||||
let q = vals.get(&joint).map(Vec::as_slice).unwrap_or(&[]);
|
||||
through(self.joints.get(&joint)?, self.frames.get(&joint)?, q, from_a, t)
|
||||
}
|
||||
|
||||
/// Where every placed occurrence is for these values.
|
||||
fn place(&self, vals: &Values) -> BTreeMap<u64, Mat> {
|
||||
let mut placed = self.fixed.clone();
|
||||
for occ in &self.order {
|
||||
let Some(&(from, joint, from_a)) = self.parent.get(occ) else { continue };
|
||||
if let Some(m) = placed.get(&from).and_then(|t| self.step(joint, from_a, t, vals)) {
|
||||
placed.insert(*occ, m);
|
||||
}
|
||||
}
|
||||
placed
|
||||
}
|
||||
|
||||
/// The fixed occurrence an occurrence is placed from, and the tree joints from it up to that one.
|
||||
fn path(&self, mut occ: u64) -> (u64, Vec<(u64, bool)>) {
|
||||
let mut up = Vec::new();
|
||||
while let Some(&(from, joint, from_a)) = self.parent.get(&occ) {
|
||||
if up.len() > self.order.len() {
|
||||
break;
|
||||
}
|
||||
up.push((joint, from_a));
|
||||
occ = from;
|
||||
}
|
||||
(occ, up)
|
||||
}
|
||||
|
||||
/// One occurrence's world placement for these values.
|
||||
fn world(&self, occ: u64, vals: &Values) -> Option<Mat> {
|
||||
let (root, up) = self.path(occ);
|
||||
let mut t = *self.fixed.get(&root)?;
|
||||
for (joint, from_a) in up.iter().rev() {
|
||||
t = self.step(*joint, *from_a, &t, vals)?;
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// Values the loop solver moves at most in one group of loops, and loop-closing joints it takes
|
||||
/// on at most: larger ones are only checked.
|
||||
const MAX_LOOP_VALUES: usize = 64;
|
||||
const MAX_LOOPS: usize = 100;
|
||||
/// Residual evaluations one solve may spend on all its loops.
|
||||
const MAX_LOOP_WORK: usize = 200_000;
|
||||
|
||||
/// Held joint values by (joint, value index): the value each moves from.
|
||||
pub type Held = BTreeMap<(u64, usize), f64>;
|
||||
|
||||
/// The values that set the `around` joints' values through motion links, followed back through
|
||||
/// every link to them (several links may write one value; `apply_links`' order decides which
|
||||
/// wins on the way) for as many links as `apply_links` passes: all the values reached, and those
|
||||
/// that no link sets (the ones a solve can move).
|
||||
fn link_chains(asm: &Assembly, tree: &Tree, around: &BTreeSet<u64>, linked: &BTreeSet<(u64, usize)>) -> (BTreeSet<(u64, usize)>, Vec<(u64, usize)>) {
|
||||
let (mut feeds, mut starts) = (BTreeSet::new(), Vec::new());
|
||||
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_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() {
|
||||
break;
|
||||
}
|
||||
for &(id, i) in &next {
|
||||
feeds.insert((id, i));
|
||||
if !linked.contains(&(id, i)) && tree.joints.get(&id).is_some_and(|j| i < j.kind.dofs().len()) {
|
||||
starts.push((id, i));
|
||||
}
|
||||
}
|
||||
seen.extend(next.iter().copied());
|
||||
frontier = next;
|
||||
}
|
||||
(feeds, starts)
|
||||
}
|
||||
|
||||
/// The held values among `touches` that move: (joint, index, from, to), `from` within the
|
||||
/// joint's limits. A suppressed joint still sets values through its motion links.
|
||||
fn held_moves(asm: &Assembly, held: &Held, touches: &BTreeSet<(u64, usize)>, vals: &Values) -> Vec<(u64, usize, f64, f64)> {
|
||||
let mut moves = Vec::new();
|
||||
for ((id, i), from) in held.iter().filter(|(v, _)| touches.contains(v)) {
|
||||
let (Some(j), Some(to)) = (asm.joints.iter().find(|j| j.id == *id), vals.get(id).and_then(|q| q.get(*i))) else { continue };
|
||||
let from = match j.limits.get(*i).copied().flatten() {
|
||||
Some((lo, hi)) => from.max(lo.min(hi)).min(hi.max(lo)),
|
||||
None => *from,
|
||||
};
|
||||
if from != *to {
|
||||
moves.push((*id, *i, from, *to));
|
||||
}
|
||||
}
|
||||
moves
|
||||
}
|
||||
|
||||
/// Close the loops, each group of loops that share joints on its own: see `close_group`.
|
||||
/// Returns the moved joints' new stored values (and updates `vals`), and the closing joints of
|
||||
/// the groups whose held values moved but which can't stay closed on the way (nothing in those
|
||||
/// moves; a group that can't close without moving is left to the conflict check).
|
||||
fn close_loops(tree: &Tree, closing: &[u64], asm: &Assembly, held: &Held, vals: &mut Values) -> (Values, BTreeSet<u64>) {
|
||||
let (mut changed, mut stuck) = (BTreeMap::new(), BTreeSet::new());
|
||||
if closing.len() > MAX_LOOPS {
|
||||
return (Values::new(), stuck);
|
||||
}
|
||||
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
|
||||
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);
|
||||
match close_group(tree, group, around, &moves, asm, held, &linked, &spent, vals) {
|
||||
Some(m) => changed.extend(m),
|
||||
None if !moves.is_empty() => stuck.extend(group.iter().copied()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// The stored values with what each group moved (groups may move different values of one
|
||||
// joint).
|
||||
let mut moved = Values::new();
|
||||
for ((id, i), v) in changed {
|
||||
let Some(j) = tree.joints.get(&id) else { continue };
|
||||
let q = moved.entry(id).or_insert_with(|| {
|
||||
let mut q = j.values.clone();
|
||||
q.resize(j.kind.dofs().len(), 0.0);
|
||||
q
|
||||
});
|
||||
if let Some(slot) = q.get_mut(i) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
(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
|
||||
/// start is closed first, from where it is. Then the held values `moves` go from the values they
|
||||
/// are held from to their current ones in small steps, each solved from the last, so the mechanism
|
||||
/// stays on its branch (an open four-bar does not flip crossed). Returns the values that changed
|
||||
/// from what is stored, by (joint, index), and updates `vals` (empty when there is nothing to do
|
||||
/// or the group is too big to solve), or `None` when the loops can't close, or can't stay closed
|
||||
/// on the way.
|
||||
fn close_group(
|
||||
tree: &Tree,
|
||||
closing: &[u64],
|
||||
around: &BTreeSet<u64>,
|
||||
moves: &[(u64, usize, f64, f64)],
|
||||
asm: &Assembly,
|
||||
held: &Held,
|
||||
linked: &BTreeSet<(u64, usize)>,
|
||||
spent: &std::cell::Cell<usize>,
|
||||
vals: &mut Values,
|
||||
) -> Option<BTreeMap<(u64, usize), f64>> {
|
||||
let vars = unknowns(tree, around, asm, linked, held);
|
||||
if vars.len() > MAX_LOOP_VALUES {
|
||||
return Some(BTreeMap::new());
|
||||
}
|
||||
let bounds: Vec<(f64, f64)> = vars
|
||||
.iter()
|
||||
.map(|(id, i)| match tree.joints.get(id).and_then(|j| j.limits.get(*i).copied().flatten()) {
|
||||
Some((lo, hi)) => (lo.min(hi), hi.max(lo)),
|
||||
None => (f64::NEG_INFINITY, f64::INFINITY),
|
||||
})
|
||||
.collect();
|
||||
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`).
|
||||
let place = |work: &mut Values, x: &[f64], t: f64| {
|
||||
for (id, i, from, to) in moves {
|
||||
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
|
||||
*slot = from + (to - from) * t;
|
||||
}
|
||||
}
|
||||
for ((id, i), (v, (lo, hi))) in vars.iter().zip(x.iter().zip(&bounds)) {
|
||||
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
|
||||
*slot = v.max(*lo).min(*hi);
|
||||
}
|
||||
}
|
||||
apply_links(asm, work);
|
||||
};
|
||||
let x0: Vec<f64> = vars.iter().map(|(id, i)| vals.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
|
||||
// Steps that move no value around the loops (links included) more than 5 degrees or a
|
||||
// twentieth of the loops' size.
|
||||
// How many such steps apart two sets of values are, over the loops' values.
|
||||
let stride = |a: &Values, b: &Values| -> f64 {
|
||||
let mut most = 0.0f64;
|
||||
for j in around.iter().filter_map(|id| tree.joints.get(id)) {
|
||||
for i in 0..j.kind.dofs().len() {
|
||||
let (Some(p), Some(q)) = (a.get(&j.id).and_then(|v| v.get(i)), b.get(&j.id).and_then(|v| v.get(i))) else { continue };
|
||||
let step = if j.kind.is_angle(i) { 5f64.to_radians() } else { 0.05 * scale };
|
||||
most = most.max((q - p).abs() / step);
|
||||
}
|
||||
}
|
||||
most
|
||||
};
|
||||
let effective = |x: &[f64], t: f64| {
|
||||
let mut v = vals.clone();
|
||||
place(&mut v, x, t);
|
||||
v
|
||||
};
|
||||
let mut first = 1.0f64;
|
||||
if !moves.is_empty() {
|
||||
first = first.min(1.0 / stride(&effective(&x0, 0.0), &effective(&x0, 1.0)));
|
||||
}
|
||||
let mut work = vals.clone();
|
||||
// How far each closing joint is from meeting with the held values `t` of the way along:
|
||||
// three points of B's joint frame, where the joint wants them against where the tree puts them.
|
||||
let mut gaps = |x: &[f64], t: f64| -> Option<Vec<f64>> {
|
||||
spent.set(spent.get() + 1);
|
||||
if spent.get() > MAX_LOOP_WORK {
|
||||
return None;
|
||||
}
|
||||
place(&mut work, x, t);
|
||||
let mut out = Vec::with_capacity(closing.len() * 9);
|
||||
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
|
||||
// rocker driven a full turn ends where it started, but can't get there.)
|
||||
if moves.is_empty() && gaps(&x0, 1.0).is_some_and(|r| largest(&r) <= tol) {
|
||||
return Some(BTreeMap::new());
|
||||
}
|
||||
// A loop open at the start closes first.
|
||||
let mut x = x0;
|
||||
if largest(&gaps(&x, 0.0)?) > tol && !least_squares(&mut |x| gaps(x, 0.0), &mut x, &bounds, tol) {
|
||||
return None;
|
||||
}
|
||||
let (mut t, mut dt) = (if moves.is_empty() { 1.0 } else { 0.0 }, first);
|
||||
let mut at = effective(&x, t);
|
||||
while t < 1.0 {
|
||||
if dt < first * 1e-4 {
|
||||
return None;
|
||||
}
|
||||
let t1 = (t + dt).min(1.0);
|
||||
let mut trial = x.clone();
|
||||
if least_squares(&mut |x| gaps(x, t1), &mut trial, &bounds, tol) {
|
||||
// A step that moved some loop value too far (through a link and a limit, or close to
|
||||
// a dead point) is taken again in halves.
|
||||
let next = effective(&trial, t1);
|
||||
if stride(&at, &next) > 2.0 {
|
||||
dt *= 0.5;
|
||||
continue;
|
||||
}
|
||||
(x, t, dt, at) = (trial, t1, (dt * 2.0).min(first), next);
|
||||
} else if spent.get() > MAX_LOOP_WORK {
|
||||
return None;
|
||||
} else {
|
||||
dt *= 0.5;
|
||||
}
|
||||
}
|
||||
if largest(&gaps(&x, 1.0)?) > tol {
|
||||
return None;
|
||||
}
|
||||
// The values that changed from what is stored.
|
||||
let moved = vars
|
||||
.iter()
|
||||
.zip(&x)
|
||||
.filter(|((id, i), v)| tree.joints.get(id).is_some_and(|j| j.values.get(*i).is_none_or(|s| (s - *v).abs() > 1e-12)))
|
||||
.map(|(var, v)| (*var, *v))
|
||||
.collect();
|
||||
*vals = work;
|
||||
Some(moved)
|
||||
}
|
||||
|
||||
fn largest(r: &[f64]) -> f64 {
|
||||
r.iter().fold(0.0f64, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
/// Levenberg–Marquardt: move `x` (kept within `bounds`) until every value of `f` is within
|
||||
/// `tol` of zero. False when it can't get there.
|
||||
/// 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 };
|
||||
if n == 0 {
|
||||
return largest(&r) <= tol;
|
||||
}
|
||||
let mut lambda = 1e-3;
|
||||
for _ in 0..50 {
|
||||
if largest(&r) <= tol {
|
||||
return true;
|
||||
}
|
||||
// The Jacobian by central differences, then the normal equations JᵀJ δ = −Jᵀr.
|
||||
let m = r.len();
|
||||
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() {
|
||||
let row = jac.get(i * n..(i + 1) * n).unwrap_or(&[]);
|
||||
for (p, a) in row.iter().enumerate().filter(|(_, a)| **a != 0.0) {
|
||||
if let Some(gp) = g.get_mut(p) {
|
||||
*gp -= a * ri;
|
||||
}
|
||||
for (q, b) in row.iter().enumerate().skip(p) {
|
||||
jtj.add(p, q, a * b);
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in 0..n {
|
||||
for q in 0..p {
|
||||
jtj.set(p, q, jtj.get(q, p));
|
||||
}
|
||||
}
|
||||
let mut stepped = false;
|
||||
while lambda < 1e12 {
|
||||
let mut a = jtj.clone();
|
||||
for p in 0..n {
|
||||
a.add(p, p, lambda * (1.0 + jtj.get(p, p)));
|
||||
}
|
||||
if let Some(d) = linalg::solve(a, g.clone()) {
|
||||
let next: Vec<f64> = x.iter().zip(&d).zip(bounds).map(|((v, dv), (lo, hi))| (v + dv).max(*lo).min(*hi)).collect();
|
||||
if let Some(rn) = f(&next)
|
||||
&& norm(&rn) < norm(&r)
|
||||
{
|
||||
*x = next;
|
||||
r = rn;
|
||||
lambda = (lambda / 3.0).max(1e-9);
|
||||
stepped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
lambda *= 4.0;
|
||||
}
|
||||
if !stepped {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
largest(&r) <= tol
|
||||
}
|
||||
|
||||
/// Place occurrences from the joint graph.
|
||||
pub fn solve(doc: &Document, st: &ModelState) -> Solution {
|
||||
solve_holding(doc, st, &BTreeMap::new())
|
||||
}
|
||||
|
||||
/// Place occurrences from the joint graph. Joints that close loops are met by moving the values
|
||||
/// of the other joints around the loop. The `held` values (the ones being driven) keep theirs,
|
||||
/// moving there from the values given (where the loops last closed) so the others follow them.
|
||||
pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
|
||||
let asm = &doc.assembly;
|
||||
let mut sol = Solution::default();
|
||||
let joints: Vec<&Joint> = asm.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect();
|
||||
let vals = joint_values(asm);
|
||||
let mut vals = joint_values(asm);
|
||||
// Joint frames in component coordinates (errors leave the joint out).
|
||||
let mut frames: BTreeMap<u64, (Mat, Mat)> = BTreeMap::new();
|
||||
for j in &joints {
|
||||
@@ -403,30 +1049,15 @@ pub fn solve(doc: &Document, st: &ModelState) -> Solution {
|
||||
dof.insert(j.a.occurrence, 0);
|
||||
}
|
||||
sol.fixed = placed.keys().copied().collect();
|
||||
let flip_m = |j: &Joint| {
|
||||
let mut m = rot_z(j.angle);
|
||||
m[3][2] = j.offset;
|
||||
// Origins mate (B's z against A's, as faces meet) unless flipped.
|
||||
if !j.flip { mat_mul(&m, &[[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } else { m }
|
||||
};
|
||||
// B's world transform from A's (or A's from B's, going the other way).
|
||||
let through = |j: &Joint, from_a: bool, t: &Mat| -> Option<Mat> {
|
||||
let (fa, fb) = frames.get(&j.id)?;
|
||||
let q = vals.get(&j.id).cloned().unwrap_or_default();
|
||||
let link = mat_mul(&mat_mul(&motion(j.kind, &q), &flip_m(j)), &mat_inverse(fb)?);
|
||||
if from_a {
|
||||
Some(mat_mul(&mat_mul(t, fa), &link))
|
||||
} else {
|
||||
// T_A = T_B · link⁻¹ · F_a⁻¹
|
||||
Some(mat_mul(&mat_mul(t, &mat_inverse(&link)?), &mat_inverse(fa)?))
|
||||
}
|
||||
};
|
||||
let mut tree =
|
||||
// Every joint's values are known, suppressed ones too (their motion links still set values).
|
||||
Tree { joints: asm.joints.iter().map(|j| (j.id, j)).collect(), frames, fixed: placed.clone(), parent: BTreeMap::new(), order: Vec::new() };
|
||||
let mut used: BTreeSet<u64> = BTreeSet::new();
|
||||
let mut queue: VecDeque<u64> = placed.keys().copied().collect();
|
||||
while let Some(occ) = queue.pop_front() {
|
||||
let Some(t) = placed.get(&occ).copied() else { continue };
|
||||
for j in &joints {
|
||||
if used.contains(&j.id) || !frames.contains_key(&j.id) {
|
||||
if used.contains(&j.id) || !tree.frames.contains_key(&j.id) {
|
||||
continue;
|
||||
}
|
||||
let (other, from_a) = if j.a.occurrence == occ {
|
||||
@@ -439,29 +1070,50 @@ pub fn solve(doc: &Document, st: &ModelState) -> Solution {
|
||||
if placed.contains_key(&other) {
|
||||
continue;
|
||||
}
|
||||
let Some(m) = through(j, from_a, &t) else { continue };
|
||||
let Some(m) = tree.step(j.id, from_a, &t, &vals) else { continue };
|
||||
used.insert(j.id);
|
||||
placed.insert(other, m);
|
||||
tree.parent.insert(other, (occ, j.id, from_a));
|
||||
tree.order.push(other);
|
||||
dof.insert(other, dof.get(&occ).copied().unwrap_or(0) + j.kind.dofs().len());
|
||||
queue.push_back(other);
|
||||
}
|
||||
}
|
||||
// Joints that close loops: do they agree?
|
||||
for j in &joints {
|
||||
if used.contains(&j.id) || !frames.contains_key(&j.id) {
|
||||
continue;
|
||||
// Joints that close loops: move the loops' values to meet them, then check they agree.
|
||||
let closing: Vec<u64> = joints
|
||||
.iter()
|
||||
.filter(|j| !used.contains(&j.id) && tree.frames.contains_key(&j.id))
|
||||
.filter(|j| placed.contains_key(&j.a.occurrence) && placed.contains_key(&j.b.occurrence))
|
||||
.map(|j| j.id)
|
||||
.collect();
|
||||
let mut stuck = BTreeSet::new();
|
||||
if !closing.is_empty() {
|
||||
(sol.values, stuck) = close_loops(&tree, &closing, asm, held, &mut vals);
|
||||
if !sol.values.is_empty() {
|
||||
placed = tree.place(&vals);
|
||||
}
|
||||
}
|
||||
for id in &closing {
|
||||
let Some(j) = tree.joints.get(id) else { continue };
|
||||
let (Some(ta), Some(tb)) = (placed.get(&j.a.occurrence), placed.get(&j.b.occurrence)) else { continue };
|
||||
let Some(want) = through(j, true, ta) else { continue };
|
||||
let (dt, da) = mismatch(&want, tb);
|
||||
let (Some(want), Some((_, fb))) = (tree.step(*id, true, ta, &vals), tree.frames.get(id)) else { continue };
|
||||
// Measured at the joint, where the loop solver closes it.
|
||||
let (dt, da) = mismatch(&mat_mul(&want, fb), &mat_mul(tb, fb));
|
||||
if dt > 1e-6 || da > 1e-8 {
|
||||
sol.conflicts.push((
|
||||
j.id,
|
||||
j.name.clone(),
|
||||
format!("off by {dt:.4} mm and {:.3}° from the placement the other joints give", da.to_degrees()),
|
||||
));
|
||||
} else if stuck.contains(id) {
|
||||
// It meets there, but the loop would have to come apart on the way (a rocker
|
||||
// driven a full turn ends where it started).
|
||||
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);
|
||||
}
|
||||
@@ -472,10 +1124,16 @@ pub fn solve(doc: &Document, st: &ModelState) -> Solution {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Apply a solution: occurrence transforms (in their parents) for the occurrences it places.
|
||||
/// Returns true when something moved.
|
||||
/// Apply a solution: occurrence transforms (in their parents) for the occurrences it places,
|
||||
/// and the values of the joints that moved to close loops. Returns true when something moved.
|
||||
pub fn apply_joint_solution(&mut self, sol: &Solution) -> bool {
|
||||
let mut changed = false;
|
||||
for (id, q) in &sol.values {
|
||||
if let Some(j) = self.assembly.joints.iter_mut().find(|j| j.id == *id) {
|
||||
j.values.clone_from(q);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let parents: BTreeMap<u64, u64> = self.occurrences.iter().map(|o| (o.id, o.parent)).collect();
|
||||
for (occ, world) in &sol.world {
|
||||
let parent = parents.get(occ).copied().unwrap_or(0);
|
||||
@@ -524,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)));
|
||||
@@ -536,4 +1236,12 @@ mod tests {
|
||||
let f = frame(Vec3::new(1.0, 2.0, 3.0), Vec3::Z, None).unwrap();
|
||||
assert!(close(&mat_mul(&f, &mat_inverse(&f).unwrap()), &IDENTITY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_resolves_tiny_turns() {
|
||||
let (_, a) = mismatch(&rot_z(1e-9), &IDENTITY);
|
||||
assert!((a - 1e-9).abs() < 1e-15, "{a}");
|
||||
let (_, a) = mismatch(&rot_z(3.0), &IDENTITY);
|
||||
assert!((a - 3.0).abs() < 1e-12, "{a}");
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -9,16 +9,20 @@ 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;
|
||||
pub mod face_anchor;
|
||||
mod feature_geometry;
|
||||
mod feature_refs;
|
||||
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
|
||||
pub mod format;
|
||||
mod format_offsets;
|
||||
@@ -26,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;
|
||||
@@ -34,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;
|
||||
|
||||
@@ -47,9 +55,10 @@ pub use assembly::{
|
||||
};
|
||||
pub use document::*;
|
||||
pub use eval::{
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for, edge_refs,
|
||||
face_point_names, face_point_state, hole_direction_at, object_height, parse_metric_thread, pattern_matrices, revolve_extent,
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for,
|
||||
edge_refs, face_point_names, face_point_state, hole_direction_at, object_height, parse_metric_thread, pattern_matrices, revolve_extent,
|
||||
revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance, world_state,
|
||||
world_state_reusing,
|
||||
};
|
||||
pub use params::{ParamRow, ParamUser, cycles};
|
||||
pub use solidcraft_kernel as kernel;
|
||||
@@ -71,6 +80,8 @@ pub enum DocError {
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DocError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod feature_walk_tests;
|
||||
#[cfg(test)]
|
||||
mod named_projection_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+121
-2
@@ -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).
|
||||
@@ -511,7 +511,22 @@ fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
|
||||
}
|
||||
};
|
||||
let mut order = cs.clone();
|
||||
order.sort_by(|a, b| key(a.1).partial_cmp(&key(b.1)).unwrap_or(std::cmp::Ordering::Equal));
|
||||
// Pieces lying almost level along the sort axis differ only in the last bits of their centre:
|
||||
// the centre is the mean of a tessellated boundary, and that depends on where the loop's
|
||||
// polyline starts, which can differ between processes. Comparing the keys with a tolerance
|
||||
// makes those pieces equal, so the stable sort keeps the order they arrived in and a name
|
||||
// keeps its number across runs (solidcraft#569). total_cmp keeps a NaN deterministic.
|
||||
let tol = ext.x.max(ext.y).max(ext.z).max(1.0) * 1e-9;
|
||||
let bucket = |c: Vec3| {
|
||||
let k = key(c);
|
||||
// Rounding onto the grid, rather than comparing with a tolerance, is what keeps this a
|
||||
// total order: `a` and `b` in one bucket and `b` and `c` in the next must not leave `a`
|
||||
// and `c` in different ones, or the sort has no order to work with. Since Rust 1.81
|
||||
// `sort_by` may panic on a comparator that is not one. Two pieces can still flip when
|
||||
// they straddle a bucket edge; that is the price of a key that ignores their low bits.
|
||||
((k.0 / tol).round() as i64, (k.1 / tol).round() as i64, (k.2 / tol).round() as i64)
|
||||
};
|
||||
order.sort_by_key(|a| bucket(a.1));
|
||||
for (k, (j, _)) in order.iter().enumerate() {
|
||||
if let Some(slot) = names.get_mut(*j) {
|
||||
*slot = format!("{n}{mark}{}", k + 1);
|
||||
@@ -741,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.
|
||||
@@ -760,6 +840,45 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Pieces lying almost level along the sort axis must keep the order they arrived in, whatever
|
||||
/// the low bits of their centres do. The centre is the mean of a tessellated boundary, so those
|
||||
/// bits depend on where the loop's polyline starts and can differ between processes — which is
|
||||
/// how a name's number used to swap between runs (solidcraft#569).
|
||||
#[test]
|
||||
fn level_pieces_keep_their_input_order() {
|
||||
let piece = |dx: f64| vec![Vec3::new(10.0 + dx, 0.0, 0.0), Vec3::new(10.0 + dx, 0.0, 5.0)];
|
||||
for (first, second) in [(0.0, 1e-12), (1e-12, 0.0)] {
|
||||
let mut names = vec!["f".to_string(), "f".to_string()];
|
||||
let pts = vec![piece(first), piece(second)];
|
||||
number_pieces(&mut names, &pts, '#');
|
||||
assert_eq!(names, vec!["f#1".to_string(), "f#2".to_string()], "input order not kept for {first} then {second}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Three pieces spaced just under the tolerance apart: `a` and `b` in one bucket and `b` and `c`
|
||||
/// in the next, so a tolerance comparison would call `a≈b` and `b≈c` while `a<c` — the
|
||||
/// non-transitivity that leaves `sort_by` without a total order to work with. The key must order
|
||||
/// them by bucket, and within a bucket by the order they arrived in.
|
||||
#[test]
|
||||
fn pieces_just_under_the_tolerance_apart_still_have_a_total_order() {
|
||||
let piece = |dx: f64| vec![Vec3::new(10.0 + dx, 0.0, 0.0), Vec3::new(10.0 + dx, 0.0, 5.0)];
|
||||
// The grid is the spread of the centres floored at 1e-9; these centres span under 1.0, so it
|
||||
// is 1e-9 and the three offsets below sit in buckets 0, 0 and 1.
|
||||
let grid = 1e-9;
|
||||
let step = grid * 0.4;
|
||||
for ((a, b, c), want) in [
|
||||
((0.0, step, 2.0 * step), ["f#1", "f#2", "f#3"]),
|
||||
((2.0 * step, step, 0.0), ["f#3", "f#1", "f#2"]),
|
||||
((0.0, 2.0 * step, step), ["f#1", "f#3", "f#2"]),
|
||||
((step, 0.0, 2.0 * step), ["f#1", "f#2", "f#3"]),
|
||||
] {
|
||||
let mut names = vec!["f".to_string(); 3];
|
||||
let pts = [a, b, c].iter().map(|d| piece(*d)).collect::<Vec<_>>();
|
||||
number_pieces(&mut names, &pts, '#');
|
||||
assert_eq!(names, want, "wrong order for offsets {a} {b} {c}");
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A cone's tip repeats its apex vertex, so a triangle can have a zero-length edge. The nearest
|
||||
|
||||
+66
-321
@@ -9,336 +9,50 @@ 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};
|
||||
|
||||
fn plane_inputs_mut<'a>(p: &'a mut PlaneRef, v: &mut Vec<(&'static str, &'a mut String, Kind)>) {
|
||||
match p {
|
||||
PlaneRef::Offset { base, distance } => {
|
||||
v.push(("Offset", distance, Kind::Length));
|
||||
plane_inputs_mut(base, v);
|
||||
}
|
||||
PlaneRef::AtAngle { base, angle, .. } => {
|
||||
v.push(("Angle", angle, Kind::Angle));
|
||||
plane_inputs_mut(base, v);
|
||||
}
|
||||
PlaneRef::AlongPath { t, .. } => v.push(("Distance (0 to 1)", t, Kind::Unitless)),
|
||||
_ => {}
|
||||
}
|
||||
/// 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
|
||||
/// keeps bare numbers in mm whatever the design's units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ExprRole {
|
||||
Input,
|
||||
Placement,
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
/// The feature's numeric inputs: (label, expression, kind), in a fixed order.
|
||||
pub fn inputs_mut(&mut self) -> Vec<(&'static str, &mut String, Kind)> {
|
||||
use Kind::{Angle as A, Length as L, Unitless as U};
|
||||
let mut v: Vec<(&'static str, &mut String, Kind)> = Vec::new();
|
||||
match self {
|
||||
FeatureKind::Sketch { plane, .. }
|
||||
| FeatureKind::ConstructionPlane { plane }
|
||||
| FeatureKind::Split { plane, .. }
|
||||
| FeatureKind::SplitFace { plane, .. }
|
||||
| FeatureKind::Mirror { plane, .. } => plane_inputs_mut(plane, &mut v),
|
||||
FeatureKind::ConstructionAxis { def } => {
|
||||
for r in def.refs_mut() {
|
||||
if let crate::construct::GeoRef::Plane { plane } = r {
|
||||
plane_inputs_mut(plane, &mut v);
|
||||
}
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
self.walk_exprs_mut(&mut |(label, e, kind, role)| {
|
||||
if role == ExprRole::Input {
|
||||
v.push((label, e, kind));
|
||||
}
|
||||
FeatureKind::ConstructionPoint { def } => match def {
|
||||
crate::construct::PointDef::AlongPath { t, .. } => v.push(("Distance (0 to 1)", t, U)),
|
||||
other => {
|
||||
for r in other.refs_mut() {
|
||||
if let crate::construct::GeoRef::Plane { plane } = r {
|
||||
plane_inputs_mut(plane, &mut v);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
|
||||
v.push(("Distance", &mut extent.distance, L));
|
||||
if let Some(d) = &mut extent.distance2 {
|
||||
v.push(("Distance 2", d, L));
|
||||
}
|
||||
if let Some(d) = &mut extent.start_offset {
|
||||
v.push(("Start offset", d, L));
|
||||
}
|
||||
if let Some(d) = &mut extent.taper {
|
||||
v.push(("Taper angle", d, A));
|
||||
}
|
||||
if let Some(d) = &mut extent.to_offset {
|
||||
v.push(("To offset", d, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { angle, angle2, .. } | FeatureKind::SurfaceRevolve { angle, angle2, .. } => {
|
||||
v.push(("Angle", angle, A));
|
||||
if let Some(a) = angle2 {
|
||||
v.push(("Angle 2", a, A));
|
||||
}
|
||||
}
|
||||
FeatureKind::Fillet { radius, style, .. } => match style {
|
||||
crate::FilletStyle::Constant => v.push(("Radius", radius, L)),
|
||||
crate::FilletStyle::Curvature => v.push(("Equivalent radius", radius, L)),
|
||||
crate::FilletStyle::Chord => v.push(("Chord length", radius, L)),
|
||||
crate::FilletStyle::Variable { radius2, mid, .. } => {
|
||||
v.push(("Start radius", radius, L));
|
||||
v.push(("End radius", radius2, L));
|
||||
v.extend(mid.iter_mut().map(|(_, r)| ("Mid radius", r, L)));
|
||||
}
|
||||
crate::FilletStyle::Spline { radii, .. } => {
|
||||
v.push(("Start radius", radius, L));
|
||||
let n = radii.len();
|
||||
v.extend(radii.iter_mut().enumerate().map(|(i, r)| (if i + 1 == n { "End radius" } else { "Control radius" }, r, L)));
|
||||
}
|
||||
},
|
||||
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
|
||||
v.push(("Distance", distance, L));
|
||||
if let Some(d) = distance2 {
|
||||
v.push(("Distance 2", d, L));
|
||||
}
|
||||
if let Some(a) = angle {
|
||||
v.push(("Angle", a, A));
|
||||
}
|
||||
}
|
||||
FeatureKind::Box { length, width, height, .. } => {
|
||||
v.push(("Length", length, L));
|
||||
v.push(("Width", width, L));
|
||||
v.push(("Height", height, L));
|
||||
}
|
||||
FeatureKind::Cylinder { radius, height, .. } => {
|
||||
v.push(("Radius", radius, L));
|
||||
v.push(("Height", height, L));
|
||||
}
|
||||
FeatureKind::Sphere { radius, .. } => v.push(("Radius", radius, L)),
|
||||
FeatureKind::Torus { major, minor, .. } => {
|
||||
v.push(("Major radius", major, L));
|
||||
v.push(("Minor radius", minor, L));
|
||||
}
|
||||
FeatureKind::Pattern { pattern, .. } => match pattern {
|
||||
PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => {
|
||||
v.push(("Quantity", count1, U));
|
||||
v.push(("Spacing", spacing1, L));
|
||||
if let Some(c) = count2 {
|
||||
v.push(("Quantity 2", c, U));
|
||||
}
|
||||
if let Some(sp) = spacing2 {
|
||||
v.push(("Spacing 2", sp, L));
|
||||
}
|
||||
}
|
||||
PatternKind::Path { count, spacing, .. } => {
|
||||
v.push(("Quantity", count, U));
|
||||
v.push(("Distance", spacing, L));
|
||||
}
|
||||
PatternKind::Circular { count, angle, .. } => {
|
||||
v.push(("Quantity", count, U));
|
||||
v.push(("Total angle", angle, A));
|
||||
}
|
||||
},
|
||||
FeatureKind::Shell { thickness, .. } => v.push(("Thickness", thickness, L)),
|
||||
FeatureKind::Draft { angle, neutral, .. } => {
|
||||
v.push(("Angle", angle, A));
|
||||
plane_inputs_mut(neutral, &mut v);
|
||||
}
|
||||
FeatureKind::Hole { diameter, depth, hole, .. } => {
|
||||
v.push(("Diameter", diameter, L));
|
||||
if let Some(d) = depth {
|
||||
v.push(("Depth", d, L));
|
||||
}
|
||||
match hole {
|
||||
HoleKind::Simple => {}
|
||||
HoleKind::Drilled { tip_angle } => v.push(("Tip angle", tip_angle, A)),
|
||||
HoleKind::Counterbore { cb_diameter, cb_depth } => {
|
||||
v.push(("Counterbore diameter", cb_diameter, L));
|
||||
v.push(("Counterbore depth", cb_depth, L));
|
||||
}
|
||||
HoleKind::Countersink { cs_diameter, cs_angle } => {
|
||||
v.push(("Countersink diameter", cs_diameter, L));
|
||||
v.push(("Countersink angle", cs_angle, A));
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::Thread { length, .. } => {
|
||||
if let Some(l) = length {
|
||||
v.push(("Length", l, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Scale { factor, factors, .. } => {
|
||||
v.push(("Scale factor", factor, U));
|
||||
if let Some([x, y, z]) = factors {
|
||||
v.push(("X scale", x, U));
|
||||
v.push(("Y scale", y, U));
|
||||
v.push(("Z scale", z, U));
|
||||
}
|
||||
}
|
||||
FeatureKind::OffsetFace { distance, .. } => v.push(("Distance", distance, L)),
|
||||
FeatureKind::BoundingSolid { margin, .. } => v.push(("Margin", margin, L)),
|
||||
FeatureKind::Pipe { diameter, wall, .. } => {
|
||||
v.push(("Diameter", diameter, L));
|
||||
if let Some(w) = wall {
|
||||
v.push(("Wall", w, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Move { translate, angle, .. } => {
|
||||
let [x, y, z] = translate;
|
||||
v.push(("X distance", x, L));
|
||||
v.push(("Y distance", y, L));
|
||||
v.push(("Z distance", z, L));
|
||||
if let Some(a) = angle {
|
||||
v.push(("Angle", a, A));
|
||||
}
|
||||
}
|
||||
FeatureKind::Emboss { depth, .. } => v.push(("Depth", depth, L)),
|
||||
FeatureKind::Boss { diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset, .. } => {
|
||||
v.push(("Diameter", diameter, L));
|
||||
v.push(("Height", height, L));
|
||||
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 {
|
||||
v.push((l, e, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::Lip { width, height, gap, .. } => {
|
||||
v.push(("Width", width, L));
|
||||
v.push(("Height", height, L));
|
||||
if let Some(g) = gap {
|
||||
v.push(("Gap", g, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Rest { width, length, height, draft, thickness, .. } => {
|
||||
v.push(("Width", width, L));
|
||||
v.push(("Height", height, L));
|
||||
for (l, o, k) in [("Length", length, L), ("Draft angle", draft, A), ("Wall thickness", thickness, L)] {
|
||||
if let Some(e) = o {
|
||||
v.push((l, e, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::SnapFit { length, thickness, width, catch_depth, catch_length, .. } => {
|
||||
v.push(("Length", length, L));
|
||||
v.push(("Thickness", thickness, L));
|
||||
v.push(("Width", width, L));
|
||||
v.push(("Catch depth", catch_depth, L));
|
||||
v.push(("Catch length", catch_length, L));
|
||||
}
|
||||
FeatureKind::SheetContour { distance, .. } => v.push(("Distance", distance, L)),
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
|
||||
v.push(("Height", height, L));
|
||||
v.push(("Angle", angle, A));
|
||||
if let Some(r) = radius {
|
||||
v.push(("Bend radius", r, L));
|
||||
}
|
||||
// The edges' own values come last, so the inputs above keep their names.
|
||||
for e in edges {
|
||||
if let Some(a) = &mut e.angle {
|
||||
v.push(("Edge angle", a, A));
|
||||
}
|
||||
if let Some(h) = &mut e.height {
|
||||
v.push(("Edge height", h, L));
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetFold { angle, radius, .. } => {
|
||||
v.push(("Angle", angle, A));
|
||||
if let Some(r) = radius {
|
||||
v.push(("Bend radius", r, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetHem { length, gap, .. } => {
|
||||
v.push(("Length", length, L));
|
||||
if let Some(g) = gap {
|
||||
v.push(("Gap", g, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => {
|
||||
v.push(("Diameter", diameter, L));
|
||||
v.push(("Pitch", pitch, L));
|
||||
v.push(("Revolutions", turns, U));
|
||||
v.push(("Section size", section_size, L));
|
||||
if let Some(a) = start_angle {
|
||||
v.push(("Start angle", a, A));
|
||||
}
|
||||
}
|
||||
FeatureKind::Rib { thickness, depth, .. } => {
|
||||
v.push(("Thickness", thickness, L));
|
||||
if let Some(d) = depth {
|
||||
v.push(("Depth", d, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v),
|
||||
FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
|
||||
FeatureKind::MeshConvert { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
|
||||
FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)),
|
||||
FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v),
|
||||
FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)),
|
||||
FeatureKind::Unstitch { .. } | FeatureKind::ReverseNormal { .. } => {}
|
||||
FeatureKind::SurfaceOffset { distance, tolerance, .. } => {
|
||||
v.push(("Distance", distance, L));
|
||||
if let Some(t) = tolerance {
|
||||
v.push(("Tolerance", t, L));
|
||||
}
|
||||
}
|
||||
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 { .. } => {}
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
/// A primitive's placement point (length expressions). Its coordinates are not inputs:
|
||||
/// they get no parameter names and keep bare numbers in mm whatever the design's units, as
|
||||
/// every other position does; renames and users still find them.
|
||||
pub fn point_expr_mut(&mut self) -> Option<&mut crate::PointExpr> {
|
||||
match self {
|
||||
FeatureKind::Box { corner: p, .. }
|
||||
| FeatureKind::Cylinder { base: p, .. }
|
||||
| FeatureKind::Sphere { center: p, .. }
|
||||
| FeatureKind::Torus { center: p, .. }
|
||||
| FeatureKind::Coil { base: p, .. } => Some(p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only view of [`FeatureKind::point_expr_mut`].
|
||||
pub fn point_expr(&self) -> Option<&crate::PointExpr> {
|
||||
match self {
|
||||
FeatureKind::Box { corner: p, .. }
|
||||
| FeatureKind::Cylinder { base: p, .. }
|
||||
| FeatureKind::Sphere { center: p, .. }
|
||||
| FeatureKind::Torus { center: p, .. }
|
||||
| FeatureKind::Coil { base: p, .. } => Some(p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind).
|
||||
pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> {
|
||||
// Imports have no inputs (and their STEP text is large: don't clone it).
|
||||
if matches!(self, FeatureKind::Import { .. } | FeatureKind::MeshImport { .. }) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut c = self.clone();
|
||||
c.inputs_mut().into_iter().map(|(l, e, k)| (l, e.clone(), k)).collect()
|
||||
let mut v = Vec::new();
|
||||
self.walk_exprs(&mut |(label, e, kind, role)| {
|
||||
if role == ExprRole::Input {
|
||||
v.push((label, e.clone(), kind));
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
/// Every expression the feature uses: its inputs, then a primitive's placement coordinates.
|
||||
pub fn expressions(&self) -> Vec<&str> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,16 +101,20 @@ impl Document {
|
||||
v
|
||||
}
|
||||
|
||||
fn name_taken(&self, n: &str) -> bool {
|
||||
pub(crate) fn name_taken(&self, n: &str) -> bool {
|
||||
self.param(n).is_some() || self.features.iter().any(|f| f.param_names.iter().any(|x| x == n))
|
||||
}
|
||||
|
||||
/// The next free `dN` name.
|
||||
pub fn next_d_name(&self) -> String {
|
||||
// Every taken name at once: asking `name_taken` for d1, d2, … searched the whole
|
||||
// design once per number, quadratic in the number of named inputs.
|
||||
let taken: std::collections::HashSet<&str> =
|
||||
self.params.iter().map(|p| p.name.as_str()).chain(self.features.iter().flat_map(|f| f.param_names.iter().map(String::as_str))).collect();
|
||||
let mut n = 1;
|
||||
loop {
|
||||
let c = format!("d{n}");
|
||||
if !self.name_taken(&c) {
|
||||
if !taken.contains(c.as_str()) {
|
||||
return c;
|
||||
}
|
||||
n += 1;
|
||||
@@ -583,7 +301,15 @@ impl Document {
|
||||
let (vals, errs) = self.param_values();
|
||||
let all = self.all_param_exprs();
|
||||
let refs: BTreeMap<String, Vec<String>> = all.iter().map(|(n, e, _)| (n.clone(), expr::references(e))).collect();
|
||||
let used_by = |n: &str| -> Vec<String> { refs.iter().filter(|(_, r)| r.iter().any(|x| x == n)).map(|(k, _)| k.clone()).collect() };
|
||||
// Who uses each name, in name order: one pass over the references (a design an agent
|
||||
// built names every feature input, so a search per row was quadratic).
|
||||
let mut users: BTreeMap<&str, Vec<String>> = BTreeMap::new();
|
||||
for (k, r) in &refs {
|
||||
for x in r {
|
||||
users.entry(x.as_str()).or_default().push(k.clone());
|
||||
}
|
||||
}
|
||||
let used_by = |n: &str| -> Vec<String> { users.get(n).cloned().unwrap_or_default() };
|
||||
let value = |n: &str, k: Kind| vals.get(n).and_then(|v| v.to_kind(k).ok()).map(|x| if k == Kind::Angle { x.to_degrees() } else { x });
|
||||
let mut rows = Vec::new();
|
||||
for p in &self.params {
|
||||
@@ -626,6 +352,25 @@ impl Document {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Move user parameter `name` to just before (or with `after`, just after) user parameter
|
||||
/// `target`: the order the Parameters dialog lists them in. Nothing else changes.
|
||||
pub fn move_user_param(&mut self, name: &str, target: &str, after: bool) -> Result<()> {
|
||||
fn user(params: &[crate::Param], n: &str) -> Option<usize> {
|
||||
params.iter().position(|p| p.name == n && !p.model)
|
||||
}
|
||||
let (Some(from), Some(_)) = (user(&self.params, name), user(&self.params, target)) else {
|
||||
let missing = if user(&self.params, name).is_none() { name } else { target };
|
||||
return Err(DocError::Unknown(format!("user parameter `{missing}`")));
|
||||
};
|
||||
if name == target {
|
||||
return Err(DocError::Invalid("a parameter cannot move next to itself".into()));
|
||||
}
|
||||
let moved = self.params.remove(from);
|
||||
let at = user(&self.params, target).map_or(self.params.len(), |i| if after { i + 1 } else { i });
|
||||
self.params.insert(at, moved);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_favorite(&mut self, name: &str, on: bool) -> Result<()> {
|
||||
if !self.name_taken(name) {
|
||||
return Err(DocError::Unknown(format!("parameter `{name}`")));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -305,6 +305,12 @@ impl MaterialCard {
|
||||
/// Bound JSON traversal before cloning/deserializing it. Metadata and numeric
|
||||
/// bounds are admission limits, not claims about a real material's valid range.
|
||||
pub fn validate_card(value: &Value) -> Result<Validation> {
|
||||
admit_card(value)?.validate()
|
||||
}
|
||||
|
||||
/// Bound and deserialize a candidate before cloning it. Semantic checks belong
|
||||
/// to `validate` or the derived query; this alone does not validate the numbers.
|
||||
pub fn admit_card(value: &Value) -> Result<MaterialCard> {
|
||||
let mut pending = vec![(value, 0usize)];
|
||||
let (mut nodes, mut bytes) = (0usize, 0usize);
|
||||
while let Some((value, depth)) = pending.pop() {
|
||||
@@ -345,7 +351,69 @@ pub fn validate_card(value: &Value) -> Result<Validation> {
|
||||
}
|
||||
}
|
||||
let card: MaterialCard = serde_json::from_value(value.clone()).map_err(|e| invalid(format!("material card: {e}")))?;
|
||||
card.validate()
|
||||
Ok(card)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct DirectionalModulus {
|
||||
pub direction: [f64; 3],
|
||||
pub axes: Option<MaterialAxes>,
|
||||
pub young_modulus_pa: f64,
|
||||
pub validation: Validation,
|
||||
}
|
||||
|
||||
impl MaterialCard {
|
||||
/// Uniaxial small-strain modulus in the supplied material-local direction.
|
||||
/// This is a constitutive query, not a body load case or structural solver.
|
||||
pub fn directional_modulus(&self, direction: [f64; 3]) -> Result<DirectionalModulus> {
|
||||
let validation = self.validate()?;
|
||||
if !direction.iter().all(|x| x.is_finite()) {
|
||||
return Err(invalid("direction: expected three finite components"));
|
||||
}
|
||||
let scale = direction.iter().fold(0.0_f64, |a, x| a.max(x.abs()));
|
||||
if scale == 0. {
|
||||
return Err(invalid("direction: must be nonzero"));
|
||||
}
|
||||
let scaled = direction.map(|x| x / scale);
|
||||
let length = scaled.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||||
let direction = scaled.map(|x| x / length);
|
||||
let model = self
|
||||
.properties
|
||||
.iter()
|
||||
.find_map(|p| match &p.data {
|
||||
PropertyData::Elasticity { model } => Some(model),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| invalid("elasticity is unspecified"))?;
|
||||
let (axes, modulus) = match model {
|
||||
Elasticity::Isotropic { young_modulus_pa, .. } => (None, *young_modulus_pa),
|
||||
Elasticity::Orthotropic { axes, young_moduli_pa, shear_moduli_pa, poisson_ratios } => {
|
||||
let [x, y, z] = direction;
|
||||
let [e1, e2, e3] = *young_moduli_pa;
|
||||
let [g12, g23, g31] = *shear_moduli_pa;
|
||||
let [n12, n23, n31] = *poisson_ratios;
|
||||
let [a, b, c] = [-n12 * (e2.sqrt() / e1.sqrt()), -n23 * (e3.sqrt() / e2.sqrt()), -n31 * (e1.sqrt() / e3.sqrt())];
|
||||
let minor = 1. - a * a;
|
||||
let determinant = 1. + 2. * a * b * c - a * a - b * b - c * c;
|
||||
// Cholesky of the scaled normal compliance: evaluate the energy
|
||||
// as sums of squares instead of cancelling positive/negative terms.
|
||||
let l22 = minor.sqrt();
|
||||
let l32 = (b - a * c) / l22;
|
||||
let l33 = (determinant / minor).sqrt();
|
||||
let [u, v, w] = [x * x / e1.sqrt(), y * y / e2.sqrt(), z * z / e3.sqrt()];
|
||||
let terms = [u + a * v + c * w, l22 * v + l32 * w, l33 * w];
|
||||
let compliance = terms.iter().map(|t| t * t).sum::<f64>() + (x * y).powi(2) / g12 + (y * z).powi(2) / g23 + (z * x).powi(2) / g31;
|
||||
if !compliance.is_finite() || compliance <= 0. {
|
||||
return Err(invalid("directional compliance is unrepresentable"));
|
||||
}
|
||||
(Some(*axes), compliance.recip())
|
||||
}
|
||||
};
|
||||
if !modulus.is_finite() || modulus <= 0. {
|
||||
return Err(invalid("directional modulus is unrepresentable"));
|
||||
}
|
||||
Ok(DirectionalModulus { direction, axes, young_modulus_pa: modulus, validation })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -14,6 +14,68 @@ fn orthotropic(e: [f64; 3], nu: [f64; 3]) -> Value {
|
||||
"young_moduli_pa":e,"shear_moduli_pa":[4e9,4e9,4e9],"poisson_ratios":nu}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_isotropy_and_principal_axes_match_analytic_values() {
|
||||
let iso = admit_card(&card(isotropic(12e9, 0.25))).unwrap();
|
||||
let mut equivalent = orthotropic([12e9; 3], [0.25; 3]);
|
||||
equivalent["model"]["shear_moduli_pa"] = json!([4.8e9, 4.8e9, 4.8e9]);
|
||||
let equivalent = admit_card(&card(equivalent)).unwrap();
|
||||
for direction in [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [1., 2., 3.], [-1., 2., -3.], [f64::MAX; 3], [1e-320; 3]] {
|
||||
let a = iso.directional_modulus(direction).unwrap();
|
||||
let b = equivalent.directional_modulus(direction).unwrap();
|
||||
assert_eq!(a.young_modulus_pa, 12e9);
|
||||
assert!((b.young_modulus_pa / 12e9 - 1.).abs() < 1e-14);
|
||||
assert_eq!(a.axes, None);
|
||||
assert_eq!(b.axes, Some(MaterialAxes::WoodLrt));
|
||||
assert!((a.direction.iter().map(|x| x * x).sum::<f64>() - 1.).abs() < 1e-14);
|
||||
}
|
||||
let ortho = admit_card(&card(orthotropic([12e9, 3e9, 1e9], [0.2, 0.1, 0.02]))).unwrap();
|
||||
for (direction, e) in [([1., 0., 0.], 12e9), ([0., 1., 0.], 3e9), ([0., 0., 1.], 1e9)] {
|
||||
assert!((ortho.directional_modulus(direction).unwrap().young_modulus_pa / e - 1.).abs() < 1e-14);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_orthotropy_includes_shear_and_reciprocal_coupling() {
|
||||
let mut data = orthotropic([12e9, 3e9, 1e9], [0.2, 0.1, 0.02]);
|
||||
data["model"]["shear_moduli_pa"] = json!([2e9, 1e9, 0.5e9]);
|
||||
let material = admit_card(&card(data)).unwrap();
|
||||
// For n=(1,1,0)/sqrt(2), 1/E(n)=(S11+S22+2S12+1/G12)/4.
|
||||
let expected = 4. / (1. / 12e9 + 1. / 3e9 - 2. * 0.2 / 12e9 + 1. / 2e9);
|
||||
for d in [[1., 1., 0.], [-2., 2., 0.], [1e200, 1e200, 0.]] {
|
||||
let r = material.directional_modulus(d).unwrap();
|
||||
assert!((r.young_modulus_pa / expected - 1.).abs() < 1e-14);
|
||||
assert_eq!(r.validation.source_review_required, vec!["test"]);
|
||||
assert!(!r.validation.analysis_solver_available);
|
||||
}
|
||||
// Independent direct tensor contraction in a general non-principal direction.
|
||||
let [x, y, z] = [2. / 3., -1. / 3., 2. / 3.];
|
||||
let inverse = x * x * x * x / 12e9
|
||||
+ y * y * y * y / 3e9
|
||||
+ z * z * z * z / 1e9
|
||||
+ x * x * y * y * (1. / 2e9 - 2. * 0.2 / 12e9)
|
||||
+ y * y * z * z * (1. / 1e9 - 2. * 0.1 / 3e9)
|
||||
+ z * z * x * x * (1. / 0.5e9 - 2. * 0.02 / 1e9);
|
||||
let actual = material.directional_modulus([2., -1., 2.]).unwrap();
|
||||
assert!((actual.young_modulus_pa * inverse - 1.).abs() < 1e-14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_queries_refuse_unknown_unstable_and_nonfinite_inputs() {
|
||||
let mut material = admit_card(&card(isotropic(12e9, 0.25))).unwrap();
|
||||
for d in [[0.; 3], [f64::NAN, 0., 1.], [0., f64::INFINITY, 0.]] {
|
||||
assert!(material.directional_modulus(d).is_err());
|
||||
}
|
||||
material.properties.clear();
|
||||
assert!(material.directional_modulus([1., 0., 0.]).is_err());
|
||||
material.properties.push(Property {
|
||||
data: PropertyData::Elasticity { model: Elasticity::Isotropic { young_modulus_pa: 12e9, poisson_ratio: 0.6 } },
|
||||
source_id: "test".into(),
|
||||
conditions: Conditions::default(),
|
||||
});
|
||||
assert!(material.directional_modulus([1., 0., 0.]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isotropic_moduli_match_closed_form_and_auxetic_values_are_valid() {
|
||||
let r = validate_card(&card(isotropic(12e9, 0.25))).unwrap();
|
||||
|
||||
@@ -308,9 +308,11 @@ mod tests {
|
||||
document
|
||||
.add_feature(
|
||||
crate::FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: feature,
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
@@ -433,9 +435,11 @@ mod tests {
|
||||
document
|
||||
.add_feature(
|
||||
crate::FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: feature,
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
|
||||
@@ -21,8 +21,23 @@ pub struct Progress {
|
||||
work_stage: std::sync::Mutex<&'static str>,
|
||||
work_done: AtomicUsize,
|
||||
work_total: AtomicUsize,
|
||||
/// A window wants the model as far as it is built, to show while the rest is ([`Progress::want_partial`]).
|
||||
partial_wanted: AtomicBool,
|
||||
/// The latest such model and when it was offered.
|
||||
partial: std::sync::Mutex<(Option<Partial>, Option<web_time::Instant>)>,
|
||||
}
|
||||
|
||||
/// The model as far as an evaluation has built it, placed in the world as the design places it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Partial {
|
||||
/// Features evaluated.
|
||||
pub features: usize,
|
||||
pub world: Arc<crate::ModelState>,
|
||||
}
|
||||
|
||||
/// How often an evaluation offers the model built so far.
|
||||
const PARTIAL_EVERY: std::time::Duration = std::time::Duration::from_millis(1500);
|
||||
|
||||
impl Default for Progress {
|
||||
fn default() -> Self {
|
||||
Progress {
|
||||
@@ -33,6 +48,8 @@ impl Default for Progress {
|
||||
work_stage: std::sync::Mutex::new(""),
|
||||
work_done: AtomicUsize::new(0),
|
||||
work_total: AtomicUsize::new(0),
|
||||
partial_wanted: AtomicBool::new(false),
|
||||
partial: std::sync::Mutex::new((None, None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +71,14 @@ impl Progress {
|
||||
pub fn features(&self) -> (usize, usize) {
|
||||
(self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed))
|
||||
}
|
||||
/// Ask the watched evaluation to offer the model as far as it is built, now and then.
|
||||
pub fn want_partial(&self) {
|
||||
self.partial_wanted.store(true, Ordering::Relaxed);
|
||||
}
|
||||
/// The model as far as it was built when last offered, once (`None` until there is a newer one).
|
||||
pub fn take_partial(&self) -> Option<Partial> {
|
||||
self.partial.lock().ok().and_then(|mut p| p.0.take())
|
||||
}
|
||||
/// Long work inside a feature, such as reading a large STEP file: (step, done, total);
|
||||
/// `None` while there is none to show.
|
||||
pub fn work(&self) -> Option<(&'static str, usize, usize)> {
|
||||
@@ -146,6 +171,37 @@ impl Evaluation {
|
||||
}
|
||||
}
|
||||
|
||||
impl Evaluation {
|
||||
/// The outer evaluation has built the model up to feature `features`: a watcher that wants
|
||||
/// partial models gets it, placed by `doc`, at most every [`PARTIAL_EVERY`] (the first one
|
||||
/// after one interval, so a design that opens quickly offers none).
|
||||
pub(crate) fn offer(&self, features: usize, doc: &crate::Document, state: &Arc<crate::ModelState>) {
|
||||
if !self.outer {
|
||||
return;
|
||||
}
|
||||
let progress = CURRENT.with(|c| c.borrow().as_ref().map(|cur| cur.progress.clone()));
|
||||
let Some(progress) = progress.filter(|p| p.partial_wanted.load(Ordering::Relaxed) && !p.is_cancelled()) else { return };
|
||||
let now = web_time::Instant::now();
|
||||
let due = match progress.partial.lock() {
|
||||
Ok(mut p) => match p.1 {
|
||||
None => {
|
||||
p.1 = Some(now);
|
||||
false
|
||||
}
|
||||
Some(last) => now.duration_since(last) >= PARTIAL_EVERY,
|
||||
},
|
||||
Err(_) => false,
|
||||
};
|
||||
if !due {
|
||||
return;
|
||||
}
|
||||
let world = Arc::new(crate::world_state(doc, state));
|
||||
if let Ok(mut p) = progress.partial.lock() {
|
||||
*p = (Some(Partial { features, world }), Some(web_time::Instant::now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Evaluation {
|
||||
fn drop(&mut self) {
|
||||
CURRENT.with(|c| {
|
||||
@@ -185,4 +241,44 @@ mod tests {
|
||||
assert_eq!(p.features(), (5, 5));
|
||||
assert!(!begin(1).step(0), "the watch ends with its scope");
|
||||
}
|
||||
|
||||
/// A watcher that asks for partial models gets the model built so far, placed, now and then;
|
||||
/// one that doesn't ask, or an unwatched evaluation, never pays for it.
|
||||
#[test]
|
||||
fn partial_models_go_only_to_a_watcher_that_wants_them() {
|
||||
let mut doc = crate::Document::new("partial");
|
||||
let block = crate::FeatureKind::Box {
|
||||
corner: crate::point_expr(solidcraft_geom::Vec3::ZERO),
|
||||
length: "2 mm".into(),
|
||||
width: "3 mm".into(),
|
||||
height: "4 mm".into(),
|
||||
operation: crate::Operation::NewBody,
|
||||
};
|
||||
doc.add_feature(block, Some("Block")).unwrap();
|
||||
let mut model = crate::Model::default();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
let offer = |p: &Arc<Progress>| {
|
||||
watch(p.clone(), || {
|
||||
let e = begin(1);
|
||||
e.offer(1, &doc, &state);
|
||||
})
|
||||
};
|
||||
let p = Arc::new(Progress::default());
|
||||
offer(&p);
|
||||
assert!(p.take_partial().is_none(), "not asked for");
|
||||
p.want_partial();
|
||||
offer(&p);
|
||||
assert!(p.take_partial().is_none(), "nothing in the first interval: a quick open offers none");
|
||||
if let Ok(mut g) = p.partial.lock() {
|
||||
g.1 = web_time::Instant::now().checked_sub(PARTIAL_EVERY * 2);
|
||||
}
|
||||
offer(&p);
|
||||
let partial = p.take_partial().unwrap();
|
||||
assert_eq!(partial.features, 1);
|
||||
assert_eq!(partial.world.bodies.len(), 1);
|
||||
assert!(p.take_partial().is_none(), "taken once");
|
||||
offer(&p);
|
||||
assert!(p.take_partial().is_none(), "not again within the interval");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+502
-15
@@ -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.
|
||||
@@ -30,6 +31,8 @@ const MAX_FLAT_POINTS: usize = 16_384;
|
||||
const MAX_FLAT_SAMPLES: usize = 65_536;
|
||||
const MAX_FLAT_PIECES: usize = 4096;
|
||||
const MAX_FLAT_WORK: usize = 500_000_000;
|
||||
/// Pieces of flat material sharing less area than this (mm²) only touch.
|
||||
const TOUCH_AREA: f64 = 1e-6;
|
||||
|
||||
/// Starts the warning a fold gives when its `fixed` point touches no material: a recompute keeps
|
||||
/// the side the point lies on, and the `sheet.fold` command refuses such a point (#263).
|
||||
@@ -446,6 +449,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,
|
||||
@@ -482,6 +489,15 @@ pub struct SheetBody {
|
||||
/// pattern both carry it (#560). Derived from feature history, like `pockets`.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub bores: Vec<SheetBore>,
|
||||
/// Perforation fields (#732), flat coordinates; their holes are computed on demand and never cut into the B-rep.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub perforations: Vec<SheetPerforation>,
|
||||
/// Regions (flat coordinates) that text cut or machined, letters with their counters as
|
||||
/// holes: a record of where the sheet's text went, so that its DXF can write the letters on
|
||||
/// layers of their own (see [`SheetBody::letters`]). The cut-outs and pockets themselves are
|
||||
/// in `holes` and `pockets`. Derived from feature history, like `pockets`.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub labels: Vec<Region2>,
|
||||
/// Unfolded.
|
||||
pub flat: bool,
|
||||
/// Gap between flanges that meet at a corner.
|
||||
@@ -509,6 +525,16 @@ pub struct SheetBore {
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
/// A perforation field from a `sheet.perforate` feature (#732).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct SheetPerforation {
|
||||
pub feature: u64,
|
||||
pub diameter: f64,
|
||||
pub pitch: f64,
|
||||
pub margin: f64,
|
||||
pub square: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct SheetPocket {
|
||||
pub region: Region2,
|
||||
@@ -829,6 +855,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() };
|
||||
}
|
||||
@@ -1068,7 +1097,17 @@ impl SheetBody {
|
||||
if points > MAX_FLAT_POINTS || out.len() >= MAX_FLAT_PIECES {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
out.push(Cell { piece, outer: region.outer.segs, holes: region.holes.into_iter().map(|l| l.segs).collect() });
|
||||
// A piece shorter than the model's tolerance is no edge: a cut-out's corner a hair
|
||||
// beside a bend line comes out of the booleans as corners a hair apart, on the
|
||||
// panel and on the bend, joined by such an edge or by a loop of them on its own.
|
||||
// The sew welds the corners left on either side.
|
||||
let hairless = |l: Loop2| -> Vec<Seg2> { l.segs.into_iter().filter(|e| e.length() >= LINEAR_TOL).collect() };
|
||||
let outer = hairless(region.outer);
|
||||
if outer.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let holes = region.holes.into_iter().map(hairless).filter(|h| !h.is_empty()).collect();
|
||||
out.push(Cell { piece, outer, holes });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
@@ -1251,7 +1290,7 @@ impl SheetBody {
|
||||
// origin. Fitting planes after a distant component transfer loses precision.
|
||||
let mut local = self.clone();
|
||||
local.frame = Plane::XY;
|
||||
let body = kernel::sew(&local.faces(&cells, 0.0, self.t, &mut work)?)?;
|
||||
let body = kernel::sew_touching(&local.faces(&cells, 0.0, self.t, &mut work)?)?;
|
||||
let body = local.machine_pockets(body, &mut work)?;
|
||||
let body = local.machine_bores(body, &mut work)?;
|
||||
self.place_local_solid(body)
|
||||
@@ -1421,7 +1460,7 @@ impl SheetBody {
|
||||
})?;
|
||||
kernel::extrude_planar_regions(&self.frame, &[region], 0.0, self.t)?
|
||||
} else {
|
||||
kernel::sew(&self.faces(std::slice::from_ref(&cell), 0.0, self.t, &mut work)?)?
|
||||
kernel::sew_touching(&self.faces(std::slice::from_ref(&cell), 0.0, self.t, &mut work)?)?
|
||||
};
|
||||
if kernel::boolean(&solid, &drill, kernel::BoolOp::Intersect)?.is_none() {
|
||||
continue;
|
||||
@@ -1525,7 +1564,7 @@ impl SheetBody {
|
||||
Piece::Panel(pi) => apply_vector(&self.world(pi), Vec3::Z).cross(plane.normal()).len() > 1e-6,
|
||||
})
|
||||
.collect();
|
||||
cells.iter().map(|c| Ok(kernel::sew(&self.faces(std::slice::from_ref(c), 0.0, self.t, &mut work)?)?)).collect()
|
||||
cells.iter().map(|c| Ok(kernel::sew_touching(&self.faces(std::slice::from_ref(c), 0.0, self.t, &mut work)?)?)).collect()
|
||||
}
|
||||
|
||||
fn through_pocket_regions(&self, work: &mut FlatWork) -> Result<Vec<Region2>> {
|
||||
@@ -1569,6 +1608,26 @@ impl SheetBody {
|
||||
Ok(through.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The text the sheet carries: what [`SheetBody::labels`] records, ready to tell whether a
|
||||
/// loop of the flat pattern is a letter.
|
||||
pub fn letters(&self) -> Letters {
|
||||
let regions = self
|
||||
.labels
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
|
||||
for p in r.outer.polyline(1e-3) {
|
||||
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
|
||||
}
|
||||
// A polyline of 0.001 mm chords is within 0.001 mm of the curve.
|
||||
let pad = Vec2::new(0.01, 0.01);
|
||||
(lo - pad, hi + pad, std::iter::once(&r.outer).chain(&r.holes).flat_map(|lp| lp.segs.iter().copied()).collect())
|
||||
})
|
||||
.collect();
|
||||
Letters { regions }
|
||||
}
|
||||
|
||||
/// Pocket operations clipped to the surviving flat material, with exact depth intervals.
|
||||
pub fn machining(&self) -> Result<Vec<SheetPocket>> {
|
||||
let material = self.flat_regions()?;
|
||||
@@ -1639,6 +1698,84 @@ impl SheetBody {
|
||||
let regions = if cuts.is_empty() { regions } else { material_boolean(®ions, &cuts, kernel::BoolOp::Cut, &mut work)? };
|
||||
Ok(regions)
|
||||
}
|
||||
|
||||
/// The pieces of flat material that a cut parted from the base: `after` is this sheet's
|
||||
/// [`Self::flat_material`] after the cut, `before` the sheet before it. A piece is parted when
|
||||
/// it holds none of the base panel, lies in a piece that held some before the cut, and no
|
||||
/// other piece surrounds it: a flange cut off along its bend (with any flange on it), the
|
||||
/// part of a ring's inside that lies on a flange's edge, the tip of a flange beyond a cut
|
||||
/// through a closed corner. A ring's island and a letter's counter, inside other material,
|
||||
/// are not counted; nor are pieces an earlier cut parted, nor anything once no piece holds
|
||||
/// the base.
|
||||
pub(crate) fn parted_from_base(&self, after: &[Region2], before: &SheetBody) -> Result<Vec<Region2>> {
|
||||
if after.len() < 2 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut work = FlatWork::new();
|
||||
work.charge(after.len().saturating_mul(after.len()))?;
|
||||
let base = [planar_region(&self.base)?];
|
||||
let holds_base = |piece: &Region2, work: &mut FlatWork| -> Result<bool> {
|
||||
let common = material_boolean(std::slice::from_ref(piece), &base, kernel::BoolOp::Intersect, work)?;
|
||||
Ok(common.iter().map(Region2::area).sum::<f64>() > TOUCH_AREA)
|
||||
};
|
||||
// The cheap test first: a piece inside another's outline is an island, whatever it holds.
|
||||
let outlines: Vec<(Vec<Vec2>, Vec2, Vec2)> = after
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let pts = r.outer.polyline(1e-3);
|
||||
let lo = pts.iter().fold(Vec2::new(f64::MAX, f64::MAX), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y)));
|
||||
let hi = pts.iter().fold(Vec2::new(f64::MIN, f64::MIN), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
|
||||
(pts, lo, hi)
|
||||
})
|
||||
.collect();
|
||||
let (mut open, mut islands) = (Vec::new(), Vec::new());
|
||||
for (i, piece) in after.iter().enumerate() {
|
||||
let p = piece.interior_point();
|
||||
let inside = |(pts, lo, hi): &(Vec<Vec2>, Vec2, Vec2)| (lo.x..=hi.x).contains(&p.x) && (lo.y..=hi.y).contains(&p.y) && point_in(pts, p);
|
||||
if outlines.iter().enumerate().any(|(j, o)| j != i && inside(o)) {
|
||||
islands.push(piece);
|
||||
} else {
|
||||
open.push(piece);
|
||||
}
|
||||
}
|
||||
let (mut joined, mut loose) = (false, Vec::new());
|
||||
for piece in open {
|
||||
if holds_base(piece, &mut work)? {
|
||||
joined = true;
|
||||
} else {
|
||||
loose.push(piece);
|
||||
}
|
||||
}
|
||||
if loose.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// The base may be an island itself (a cut all round it).
|
||||
for piece in islands {
|
||||
if joined {
|
||||
break;
|
||||
}
|
||||
joined = holds_base(piece, &mut work)?;
|
||||
}
|
||||
if !joined {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// A cut only takes material away: each piece lies in the piece it was part of.
|
||||
let mut held = Vec::new();
|
||||
for piece in before.flat_material()? {
|
||||
if holds_base(&piece, &mut work)? {
|
||||
held.push(piece);
|
||||
}
|
||||
}
|
||||
Ok(loose
|
||||
.into_iter()
|
||||
.filter(|piece| {
|
||||
let p = piece.interior_point();
|
||||
held.iter().any(|h| h.contains(p))
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
|
||||
pub fn outline(&self) -> Result<Vec<Vec<Vec2>>> {
|
||||
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES {
|
||||
@@ -1695,6 +1832,97 @@ impl SheetBody {
|
||||
out
|
||||
}
|
||||
|
||||
/// The flat bend zones, one per bend.
|
||||
pub fn bend_zones(&self) -> Vec<Region2> {
|
||||
self.flanges.iter().map(|f| self.zone(f)).collect()
|
||||
}
|
||||
|
||||
/// Hole centres of a perforation field (#732), flat coordinates: a staggered (60°, odd rows shifted half a pitch)
|
||||
/// or square grid from the flat bounding box's lower-left corner plus margin + radius; a hole is kept where it is
|
||||
/// at least margin clear of the material's edges and cut-outs and of every bend zone.
|
||||
pub fn perforation_holes(&self, p: &SheetPerforation) -> Result<Vec<Vec2>> {
|
||||
let regions = self.flat_regions()?;
|
||||
let zones = self.bend_zones();
|
||||
let clear = p.diameter / 2.0 + p.margin;
|
||||
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
|
||||
let mut add = |l: &Loop2| {
|
||||
let pts = l.polyline(1e-3);
|
||||
for k in 0..pts.len() {
|
||||
if let (Some(&a), Some(&b)) = (pts.get(k), pts.get((k + 1) % pts.len())) {
|
||||
segs.push((a, b));
|
||||
}
|
||||
}
|
||||
};
|
||||
for r in ®ions {
|
||||
add(&r.outer);
|
||||
r.holes.iter().for_each(&mut add);
|
||||
}
|
||||
zones.iter().for_each(|z| add(&z.outer));
|
||||
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
|
||||
for (a, _) in &segs {
|
||||
lo = Vec2::new(lo.x.min(a.x), lo.y.min(a.y));
|
||||
hi = Vec2::new(hi.x.max(a.x), hi.y.max(a.y));
|
||||
}
|
||||
if !(lo.x <= hi.x) || !(p.pitch > 0.0) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Segments bucketed in a grid, so each hole only checks the edges near it.
|
||||
let cell = (2.0 * clear).max(p.pitch).max(1.0);
|
||||
let (nx, ny) = (((hi.x - lo.x) / cell) as usize + 1, ((hi.y - lo.y) / cell) as usize + 1);
|
||||
let mut grid: Vec<Vec<usize>> = vec![Vec::new(); nx.saturating_mul(ny)];
|
||||
let idx = |x: f64, y: f64| -> (usize, usize) {
|
||||
(((x - lo.x) / cell).floor().clamp(0.0, (nx - 1) as f64) as usize, ((y - lo.y) / cell).floor().clamp(0.0, (ny - 1) as f64) as usize)
|
||||
};
|
||||
for (i, (a, b)) in segs.iter().enumerate() {
|
||||
let (i0, j0) = idx(a.x.min(b.x) - clear, a.y.min(b.y) - clear);
|
||||
let (i1, j1) = idx(a.x.max(b.x) + clear, a.y.max(b.y) + clear);
|
||||
for j in j0..=j1 {
|
||||
for ii in i0..=i1 {
|
||||
if let Some(c) = grid.get_mut(j * nx + ii) {
|
||||
c.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let dist = |c: Vec2, (a, b): (Vec2, Vec2)| -> f64 {
|
||||
let (d, l) = (b - a, (b - a).dot(b - a));
|
||||
let t = if l > 0.0 { ((c - a).dot(d) / l).clamp(0.0, 1.0) } else { 0.0 };
|
||||
c.dist(a + d * t)
|
||||
};
|
||||
let dy = if p.square { p.pitch } else { p.pitch * 3f64.sqrt() / 2.0 };
|
||||
let mut out = Vec::new();
|
||||
let mut row = 0usize;
|
||||
loop {
|
||||
let y = lo.y + clear + row as f64 * dy;
|
||||
if y > hi.y - clear + 1e-9 {
|
||||
break;
|
||||
}
|
||||
let mut x = lo.x + clear + if !p.square && row % 2 == 1 { p.pitch / 2.0 } else { 0.0 };
|
||||
while x <= hi.x - clear + 1e-9 {
|
||||
let c = Vec2::new(x, y);
|
||||
let (i, j) = idx(x, y);
|
||||
let near = grid.get(j * nx + i).map(Vec::as_slice).unwrap_or(&[]);
|
||||
if regions.iter().any(|r| r.contains(c))
|
||||
&& !zones.iter().any(|z| z.contains(c))
|
||||
&& near.iter().all(|&k| segs.get(k).is_none_or(|&s| dist(c, s) >= clear - 1e-9))
|
||||
{
|
||||
out.push(c);
|
||||
}
|
||||
x += p.pitch;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Open area of all perforation fields (mm²): what they take out of the sheet's faces.
|
||||
pub fn perforated_area(&self) -> f64 {
|
||||
self.perforations
|
||||
.iter()
|
||||
.map(|p| self.perforation_holes(p).map_or(0.0, |h| h.len() as f64) * std::f64::consts::PI * (p.diameter / 2.0).powi(2))
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Topology counted as Fusion does: a round hole is one face bounded by two closed circular
|
||||
/// edges (one vertex each), where the sewn solid splits it into arcs. Returns the change to
|
||||
/// the sewn (faces, edges, vertices).
|
||||
@@ -2053,6 +2281,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),
|
||||
@@ -2078,7 +2307,7 @@ impl SheetBody {
|
||||
/// such end left as it is.
|
||||
fn relieve_bend_ends(&mut self, i: usize, free: (bool, bool)) -> Vec<String> {
|
||||
let Some(f) = self.flanges.get(i).cloned() else { return Vec::new() };
|
||||
if f.parent.is_some() || f.shape.is_some() {
|
||||
if f.parent.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let (width, depth) = (self.bend_relief.0, self.bend_relief.1.max(0.0));
|
||||
@@ -2487,32 +2716,42 @@ impl SheetBody {
|
||||
fn close_corners(&mut self, made: &[usize]) -> Vec<String> {
|
||||
let corners = self.corners(made);
|
||||
let mut notes = Vec::new();
|
||||
let at = |s: &SheetBody, c: Vec2| {
|
||||
let w = apply_point(&s.frame_mat(), v3(c));
|
||||
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
|
||||
};
|
||||
let mut closed = Vec::with_capacity(corners.len());
|
||||
for k in &corners {
|
||||
match self.close_corner(k) {
|
||||
Ok(()) => closed.push(*k),
|
||||
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", at(self, k.c))),
|
||||
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", self.shown(k.c))),
|
||||
}
|
||||
}
|
||||
notes.extend(self.relieve_corners(&corners, &closed));
|
||||
notes
|
||||
}
|
||||
|
||||
/// A point of the flat frame, in the part's coordinates, for a note.
|
||||
fn shown(&self, c: Vec2) -> String {
|
||||
let w = apply_point(&self.frame_mat(), v3(c));
|
||||
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
|
||||
}
|
||||
|
||||
/// Cut the rule's relief at each of `corners`, or tear those of them that are `closed`.
|
||||
/// Returns a note for every corner without its relief or not torn.
|
||||
fn relieve_corners(&mut self, corners: &[Corner], closed: &[Corner]) -> Vec<String> {
|
||||
let mut notes = Vec::new();
|
||||
match self.relief {
|
||||
ReliefShape::Notch => {}
|
||||
// The reliefs come last: they move the bends' ends, which is how the corners were found.
|
||||
ReliefShape::Round | ReliefShape::Rectangular => {
|
||||
for k in &corners {
|
||||
if let Err(why) = self.relieve_corner(k, &corners) {
|
||||
notes.push(format!("no corner relief at {}: {why}", at(self, k.c)));
|
||||
for k in corners {
|
||||
if let Err(why) = self.relieve_corner(k, corners) {
|
||||
notes.push(format!("no corner relief at {}: {why}", self.shown(k.c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A tear cuts to the flanges' closed ends, so a corner left open gets none.
|
||||
ReliefShape::Tear => {
|
||||
for k in &closed {
|
||||
for k in closed {
|
||||
if let Err(why) = self.tear_corner(k) {
|
||||
notes.push(format!("the corner at {} keeps its notch: {why}", at(self, k.c)));
|
||||
notes.push(format!("the corner at {} keeps its notch: {why}", self.shown(k.c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2785,6 +3024,7 @@ impl SheetBody {
|
||||
slope: (0.0, 0.0),
|
||||
tear: (false, false),
|
||||
shape: None,
|
||||
exact: None,
|
||||
tag: String::new(),
|
||||
});
|
||||
Ok(self.flanges.len() - 1)
|
||||
@@ -2919,6 +3159,7 @@ impl SheetBody {
|
||||
slope: (0.0, 0.0),
|
||||
tear: (false, false),
|
||||
shape: None,
|
||||
exact: None,
|
||||
tag: String::new(),
|
||||
};
|
||||
let ba = self.allowance(&probe);
|
||||
@@ -2983,6 +3224,7 @@ impl SheetBody {
|
||||
Some(j) => {
|
||||
if let Some(f) = self.flanges.get_mut(j) {
|
||||
f.shape = Some(ccw(fixed_part.clone()));
|
||||
f.exact = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2999,6 +3241,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;
|
||||
@@ -3124,6 +3367,7 @@ impl SheetBody {
|
||||
Some(j) => {
|
||||
if let Some(f) = self.flanges.get_mut(j) {
|
||||
f.shape = Some(ccw(fixed));
|
||||
f.exact = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3142,6 +3386,7 @@ impl SheetBody {
|
||||
slope: (0.0, 0.0),
|
||||
tear: (false, false),
|
||||
shape: Some(ccw(moving)),
|
||||
exact: None,
|
||||
tag: String::new(),
|
||||
});
|
||||
}
|
||||
@@ -3824,6 +4069,30 @@ fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether every segment of `lp` lies on one of `curves` (its ends and its middle, within
|
||||
/// 0.0001 mm): the loop is made of those curves, or of parts of them.
|
||||
pub fn loop_lies_on(lp: &Loop2, curves: &[Seg2]) -> bool {
|
||||
const TOL: f64 = 1e-4;
|
||||
let near = |p: Vec2| curves.iter().any(|c| c.point_at(c.closest_param(p)).dist(p) <= TOL);
|
||||
!lp.segs.is_empty() && lp.segs.iter().all(|g| near(g.start()) && near(g.mid()) && near(g.end()))
|
||||
}
|
||||
|
||||
/// Tells which loops of a sheet's flat pattern are letters (see [`SheetBody::letters`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Letters {
|
||||
/// Each text region's corners (lowest, highest) and the segments of its outlines.
|
||||
regions: Vec<(Vec2, Vec2, Vec<Seg2>)>,
|
||||
}
|
||||
|
||||
impl Letters {
|
||||
/// Whether `lp` is made of the outlines of text the sheet carries: a letter, or one of its
|
||||
/// counters.
|
||||
pub fn is_letter(&self, lp: &Loop2) -> bool {
|
||||
let Some(p) = lp.segs.first().map(Seg2::start) else { return false };
|
||||
self.regions.iter().any(|(lo, hi, curves)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y && loop_lies_on(lp, curves))
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
|
||||
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
|
||||
let (a, b) = (planar_region(a)?, planar_region(b)?);
|
||||
@@ -3844,6 +4113,8 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
|
||||
holes,
|
||||
pockets: Vec::new(),
|
||||
bores: Vec::new(),
|
||||
perforations: Vec::new(),
|
||||
labels: Vec::new(),
|
||||
flat: false,
|
||||
gap: rv.gap,
|
||||
relief: rv.corner_relief_shape,
|
||||
@@ -3900,6 +4171,8 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
|
||||
holes: Vec::new(),
|
||||
pockets: Vec::new(),
|
||||
bores: Vec::new(),
|
||||
perforations: Vec::new(),
|
||||
labels: Vec::new(),
|
||||
flat: false,
|
||||
gap: rv.gap,
|
||||
relief: rv.corner_relief_shape,
|
||||
@@ -3939,6 +4212,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);
|
||||
@@ -4043,6 +4317,41 @@ mod tests {
|
||||
kernel::measure(b).map(|m| m.volume).unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loops_lie_on_the_curves_they_are_made_of() {
|
||||
let circle = Loop2::circle(Vec2::new(5.0, 5.0), 3.0);
|
||||
let curves: Vec<Seg2> = circle.segs.clone();
|
||||
// The loop itself, started elsewhere and run the other way, and a loop of its halves.
|
||||
assert!(loop_lies_on(&circle, &curves));
|
||||
assert!(loop_lies_on(&Loop2::circle_from(Vec2::new(5.0, 5.0), 3.0, 1.0).reversed(), &curves));
|
||||
let halves: Vec<Seg2> = circle.segs.iter().flat_map(|g| [g.split_half().0, g.split_half().1]).collect();
|
||||
assert!(loop_lies_on(&Loop2 { segs: halves }, &curves));
|
||||
// Another circle, a square and an empty loop do not.
|
||||
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 3.1), &curves));
|
||||
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 2.0), &curves));
|
||||
assert!(!loop_lies_on(&rect(Vec2::ZERO, Vec2::X, Vec2::new(1.0, 1.0), Vec2::Y).outer, &curves));
|
||||
assert!(!loop_lies_on(&Loop2 { segs: Vec::new() }, &curves));
|
||||
// A loop only partly on them (a letter across the sheet's edge) is not on them.
|
||||
let notch = Loop2 { segs: vec![circle.segs[0], Seg2::Line { a: circle.segs[0].end(), b: Vec2::new(50.0, 50.0) }] };
|
||||
assert!(!loop_lies_on(¬ch, &curves));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheet_knows_the_letters_it_carries() {
|
||||
// A recorded text region with a counter: its outline and its counter are letters, the same
|
||||
// circle elsewhere, a loop near it that is another size and a loop far away are not.
|
||||
let mut s = plate(100.0, 60.0);
|
||||
let counter = Loop2::circle(Vec2::new(20.0, 20.0), 2.0).reversed();
|
||||
s.labels.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 5.0), holes: vec![counter.clone()] });
|
||||
let letters = s.letters();
|
||||
assert!(letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
|
||||
assert!(letters.is_letter(&counter.reversed()));
|
||||
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 4.0)));
|
||||
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(70.0, 20.0), 5.0)));
|
||||
// A sheet that carries none has no letters.
|
||||
assert!(!plate(100.0, 60.0).letters().is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_flange_matches_fusion() {
|
||||
// Oracle 68: 100 x 60 plate, 20 mm flange on the y = 0 top edge.
|
||||
@@ -4640,6 +4949,42 @@ mod tests {
|
||||
s.flat_regions().unwrap().iter().map(Region2::area).sum()
|
||||
}
|
||||
|
||||
/// A plate (0, 0) (100, 0) (85, 20) (0, 20) with a 30 mm inside flange on its slanted edge
|
||||
/// (2 mm sheet, R 2, K 0.5: the folded sheet holds its flat area times the thickness), and a
|
||||
/// 4 mm² triangle cut out of the flat across the bend line, with a corner on the line or a
|
||||
/// hair (0.01 to 0.1 µm) to either side of it, in the middle of the bend and near its ends.
|
||||
/// The planar booleans leave such a corner as corners a hair apart on the base and on the
|
||||
/// bend, and the refold joined them by an edge as short ("sew: a degenerate edge").
|
||||
#[test]
|
||||
fn a_cut_out_corner_a_hair_beside_a_bend_line_refolds() {
|
||||
let rule = RuleValues { bend_radius: 2.0, ..alu() };
|
||||
let outline = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(85.0, 20.0), Vec2::new(0.0, 20.0)];
|
||||
for deg in [90.0f64, 120.0] {
|
||||
let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, Region2 { outer: Loop2::polygon(&outline).ccw(), holes: vec![] });
|
||||
s.add_flange(Vec3::new(92.5, 10.0, 2.0), 30.0, deg.to_radians(), 2.0, "inside", false).unwrap();
|
||||
let (a0, v0) = (flat_area(&s), vol(&s.solid().unwrap()));
|
||||
if deg == 90.0 {
|
||||
// The base less 4 mm along the edge's normal (5 mm along x): 1750; the bend
|
||||
// 25 x 3π/2; the flange 25 x 26.
|
||||
assert!((a0 - (2400.0 + 37.5 * std::f64::consts::PI)).abs() < 1e-9, "{a0}");
|
||||
}
|
||||
// The measure follows the bend by facets.
|
||||
assert!((v0 / (a0 * 2.0) - 1.0).abs() < 1e-4, "{deg}°: {v0} vs {}", a0 * 2.0);
|
||||
let f = s.flanges[0].clone();
|
||||
for (along, off) in [(0.5, 0.0), (0.5, 1e-8), (0.5, 5e-8), (0.5, -5e-8), (0.5, 1e-7), (0.5, -1e-7), (0.16, 5e-8), (0.84, -5e-8)] {
|
||||
let what = format!("{deg}°, corner {along} along the bend, {off} mm beside it");
|
||||
let c = f.p + f.d * (f.len * along) + f.n * off;
|
||||
let triangle = Loop2::polygon(&[c, c - f.n * 2.8 - f.d * 0.4, c + f.n * 0.4 - f.d * 2.8]).ccw();
|
||||
let mut cut = s.clone();
|
||||
cut.holes.push(Region2 { outer: triangle, holes: vec![] });
|
||||
assert!((a0 - flat_area(&cut) - 4.0).abs() < 1e-6, "{what}: flat area {}", a0 - flat_area(&cut));
|
||||
let body = cut.solid().unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert!(body.validity().is_empty(), "{what}: {:?}", body.validity());
|
||||
assert!((v0 - vol(&body) - 8.0).abs() < 0.01, "{what}: {} vs 8", v0 - vol(&body));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance between two parallel 3D lines through a0→a1 and b0→b1 (None if not parallel).
|
||||
fn parallel_gap(a0: Vec3, a1: Vec3, b0: Vec3, b1: Vec3) -> Option<f64> {
|
||||
let (u, v) = ((a1 - a0).normalized()?, (b1 - b0).normalized()?);
|
||||
@@ -6798,6 +7143,103 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The plate (0, 0) (100, 0) (85, 20) (0, 20) with a 30 mm inside flange on its slanted edge
|
||||
/// (2 mm sheet, R 2, K 0.5: the folded sheet holds its flat area times the thickness), and a
|
||||
/// triangle cut out of the flat with one corner on the bend line: where the line meets the
|
||||
/// plate's edge, at either end, the triangle on the bend or on the base; in the line's middle,
|
||||
/// from either side; and on the plate's straight edge, from inside. At the plate's edge the
|
||||
/// sheet is pinched (two wedges of material meet along the thickness); in the middle the
|
||||
/// triangle's tip touches the next piece. The refold ran the face through that corner twice
|
||||
/// ("This wire is not simple").
|
||||
#[test]
|
||||
fn a_cut_out_with_a_corner_on_a_bend_line_or_the_sheets_edge_refolds() {
|
||||
let rule = RuleValues { bend_radius: 2.0, ..alu() };
|
||||
let outline = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(85.0, 20.0), Vec2::new(0.0, 20.0)];
|
||||
for deg in [90.0f64, 120.0] {
|
||||
let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, Region2 { outer: Loop2::polygon(&outline).ccw(), holes: vec![] });
|
||||
s.add_flange(Vec3::new(92.5, 10.0, 2.0), 30.0, deg.to_radians(), 2.0, "inside", false).unwrap();
|
||||
let (a0, v0) = (flat_area(&s), vol(&s.solid().unwrap()));
|
||||
let f = s.flanges[0].clone();
|
||||
let (start, end, middle) = (f.p, f.p + f.d * f.len, f.p + f.d * (f.len / 2.0));
|
||||
// The bend line runs from the plate's edge y = 0 to y = 20, the bend on its +n side,
|
||||
// square across at its ends; the base's corner at either end is at least 53.13°
|
||||
// wide, the edge running off at (0.6, -0.8) along and across the line. The triangles'
|
||||
// other corners as steps along the line and across it.
|
||||
let cases = [
|
||||
("on the bend at the line's start", start, [(2.0, 1.0), (0.4, 2.2)], 2.0),
|
||||
("on the bend at the line's end", end, [(-2.0, 1.0), (-0.4, 2.2)], 2.0),
|
||||
("on the base at the line's start", start, [(4.0, -1.0), (2.0, -2.0)], 3.0),
|
||||
("on the base at the line's middle", middle, [(-1.0, -2.0), (1.0, -2.0)], 2.0),
|
||||
("on the bend at the line's middle", middle, [(-1.0, 2.0), (1.0, 2.0)], 2.0),
|
||||
];
|
||||
for (what, c, others, area) in cases {
|
||||
let what = format!("{deg}°, {what}");
|
||||
let corners: Vec<Vec2> = std::iter::once(c).chain(others.iter().map(|(u, v)| c + f.d * *u + f.n * *v)).collect();
|
||||
let mut cut = s.clone();
|
||||
cut.holes.push(Region2 { outer: Loop2::polygon(&corners).ccw(), holes: vec![] });
|
||||
assert!((a0 - flat_area(&cut) - area).abs() < 1e-6, "{what}: flat area {}", a0 - flat_area(&cut));
|
||||
let body = cut.solid().unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert!(body.validity().is_empty() && body.shell_count() == 1, "{what}: {:?}", body.validity());
|
||||
assert!((v0 - vol(&body) - area * 2.0).abs() < 0.01, "{what}: {} vs {}", v0 - vol(&body), area * 2.0);
|
||||
}
|
||||
// A 4 mm² triangle standing on the plate's edge y = 20 at x = 40.
|
||||
let mut cut = s.clone();
|
||||
cut.holes
|
||||
.push(Region2 { outer: Loop2::polygon(&[Vec2::new(40.0, 20.0), Vec2::new(38.0, 18.0), Vec2::new(42.0, 18.0)]).ccw(), holes: vec![] });
|
||||
assert!((a0 - flat_area(&cut) - 4.0).abs() < 1e-6, "{deg}°, on the edge: flat area {}", a0 - flat_area(&cut));
|
||||
let body = cut.solid().unwrap_or_else(|e| panic!("{deg}°, on the edge: {e}"));
|
||||
assert!(body.validity().is_empty() && body.shell_count() == 1, "{deg}°, on the edge: {:?}", body.validity());
|
||||
assert!((v0 - vol(&body) - 8.0).abs() < 0.01, "{deg}°, on the edge: {} vs 8", v0 - vol(&body));
|
||||
}
|
||||
}
|
||||
|
||||
/// The plate (0, 0) (100, 0) (85, 20) (0, 20) with a 30 mm inside flange on its slanted edge
|
||||
/// (2 mm sheet, R 2, K 0.5: the folded sheet holds its flat area times the thickness), and a
|
||||
/// triangle cut out of the flat on one side of the bend line only, with a corner a hair (0.01
|
||||
/// or 0.05 µm) from the line, short of it or across: on the base, in the line's middle and
|
||||
/// near its ends; and at either end of the line, where it meets the plate's edge, on the base
|
||||
/// and on the bend. The planar booleans leave such a corner as corners a hair apart, on the
|
||||
/// base and on the bend; joined, they are a corner where the cut-out touches the bend line or
|
||||
/// the plate's edge. So the refold needs both: without the joining it failed with "sew: a
|
||||
/// degenerate edge", without the touching sew with "This wire is not simple".
|
||||
#[test]
|
||||
fn a_cut_out_on_one_side_of_a_bend_line_with_a_corner_a_hair_from_it_refolds() {
|
||||
let rule = RuleValues { bend_radius: 2.0, ..alu() };
|
||||
let outline = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(85.0, 20.0), Vec2::new(0.0, 20.0)];
|
||||
for deg in [90.0f64, 120.0] {
|
||||
let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, Region2 { outer: Loop2::polygon(&outline).ccw(), holes: vec![] });
|
||||
s.add_flange(Vec3::new(92.5, 10.0, 2.0), 30.0, deg.to_radians(), 2.0, "inside", false).unwrap();
|
||||
let (a0, v0) = (flat_area(&s), vol(&s.solid().unwrap()));
|
||||
let f = s.flanges[0].clone();
|
||||
// The corner at a fraction of the line, the triangle's other corners as steps along the
|
||||
// line and across it (the bend on its +n side; the base's corner at either end of the
|
||||
// line is at least 53.13° wide), and the triangle's area.
|
||||
let cases = [
|
||||
("on the base in the line's middle", 0.5, [(-1.0, -2.0), (1.0, -2.0)], 2.0),
|
||||
("on the base near the line's start", 0.16, [(-1.0, -2.0), (1.0, -2.0)], 2.0),
|
||||
("on the base near the line's end", 0.84, [(-1.0, -2.0), (1.0, -2.0)], 2.0),
|
||||
("on the base at the line's start", 0.0, [(3.0, -2.0), (2.0, -2.0)], 1.0),
|
||||
("on the base at the line's end", 1.0, [(-2.0, -2.0), (-3.0, -2.0)], 1.0),
|
||||
("on the bend at the line's start", 0.0, [(1.25, 2.0), (0.25, 2.0)], 1.0),
|
||||
("on the bend at the line's end", 1.0, [(-0.25, 2.0), (-1.25, 2.0)], 1.0),
|
||||
];
|
||||
for (what, along, others, area) in cases {
|
||||
// Across the line toward the bend.
|
||||
for off in [-5e-8, -1e-8, 1e-8, 5e-8] {
|
||||
let what = format!("{deg}°, {what}, {off} mm across the line");
|
||||
let c = f.p + f.d * (f.len * along) + f.n * off;
|
||||
let corners: Vec<Vec2> = std::iter::once(c).chain(others.iter().map(|(u, v)| c + f.d * *u + f.n * *v)).collect();
|
||||
let mut cut = s.clone();
|
||||
cut.holes.push(Region2 { outer: Loop2::polygon(&corners).ccw(), holes: vec![] });
|
||||
assert!((a0 - flat_area(&cut) - area).abs() < 1e-5, "{what}: flat area {}", a0 - flat_area(&cut));
|
||||
let body = cut.solid().unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert!(body.validity().is_empty() && body.shell_count() == 1, "{what}: {:?}", body.validity());
|
||||
assert!((v0 - vol(&body) - area * 2.0).abs() < 0.01, "{what}: {} vs {}", v0 - vol(&body), area * 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tear_holds_at_every_angle() {
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
@@ -7027,4 +7469,49 @@ mod tests {
|
||||
holds_the_flat_material(&s, &what);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 100 x 60 plate with a 20 mm flange on its y = 0 edge (2.5 mm, R 2.5, K 0.44): in the
|
||||
/// flat the base starts at y = t + R = 5, the bend's zone is (π/2)·(R + K·t) long and the
|
||||
/// flange's panel 20 − 5, so its far edge lies at y = 5 − (π/2)·3.6 − 15.
|
||||
#[test]
|
||||
fn a_cut_names_the_pieces_it_parts_from_the_base_and_no_others() {
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
let far = 5.0 - FRAC_PI_2 * (2.5 + 0.44 * 2.5) - 15.0;
|
||||
let mut s = plate(100.0, 60.0);
|
||||
s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
|
||||
let r = |x0: f64, y0: f64, x1: f64, y1: f64| rect(Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1));
|
||||
let ring = |c: Vec2, ro: f64, ri: f64| Region2 { outer: Loop2::circle(c, ro), holes: vec![Loop2::circle(c, ri)] };
|
||||
let parted = |before: &SheetBody, holes: &[Region2]| {
|
||||
let mut after = before.clone();
|
||||
after.holes.extend_from_slice(holes);
|
||||
after.parted_from_base(&after.flat_material().unwrap(), before).unwrap()
|
||||
};
|
||||
// A slot across the flange beside its bend parts the rest of the flange: 100 × (−3 − far).
|
||||
let slot = r(-5.0, -3.0, 105.0, -1.0);
|
||||
let p = parted(&s, std::slice::from_ref(&slot));
|
||||
assert!(p.len() == 1 && (p[0].area() - 100.0 * (-3.0 - far)).abs() < 1e-6, "{p:?}");
|
||||
// So does a ring on the flange's far edge: the half of its inside on the flange, π·2²/2.
|
||||
let p = parted(&s, &[ring(Vec2::new(50.0, far), 4.0, 2.0)]);
|
||||
assert!(p.len() == 1 && (p[0].area() - 2.0 * PI).abs() < 1e-6, "{p:?}");
|
||||
// A ring's island, on the base or on the flange, has material round it; a strip across the
|
||||
// whole sheet leaves two halves that each keep their part of the base. None is named.
|
||||
for holes in [ring(Vec2::new(50.0, 30.0), 10.0, 5.0), ring(Vec2::new(50.0, -9.0), 4.0, 2.0), r(45.0, -30.0, 55.0, 70.0)] {
|
||||
assert!(parted(&s, std::slice::from_ref(&holes)).is_empty(), "{holes:?}");
|
||||
}
|
||||
// Pieces an earlier cut parted are not named again, nor when a later cut splits them.
|
||||
let mut cut = s.clone();
|
||||
cut.holes.push(slot);
|
||||
for holes in [Region2 { outer: Loop2::circle(Vec2::new(30.0, 30.0), 4.0), holes: vec![] }, r(45.0, -30.0, 55.0, -2.0)] {
|
||||
assert!(parted(&cut, std::slice::from_ref(&holes)).is_empty(), "{holes:?}");
|
||||
}
|
||||
// Nothing is named once no piece holds the base: a cut that takes the base and both bends
|
||||
// of a sheet with flanges on two sides leaves the two flanges.
|
||||
let mut two = plate(100.0, 60.0);
|
||||
two.add_flanges(&[Vec3::new(50.0, 0.0, 2.5), Vec3::new(50.0, 60.0, 2.5)], 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
|
||||
let mut after = two.clone();
|
||||
after.holes.push(r(-5.0, -3.0, 105.0, 63.0));
|
||||
let material = after.flat_material().unwrap();
|
||||
assert_eq!(material.len(), 2);
|
||||
assert!(after.parted_from_base(&material, &two).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+675
-57
@@ -11,20 +11,49 @@
|
||||
//! two panels that is no bend is cut open: each panel ends square to the sheet, half the rule's
|
||||
//! gap from the plane halfway between the two.
|
||||
//!
|
||||
//! The bends on the fixed face are finished as the flanges of one edge flange feature are: where
|
||||
//! two meet at a corner of it, their walls are closed with the rule's gap and the corner gets the
|
||||
//! rule's corner relief (round, rectangular, a tear, or the notch the bends leave), and where one
|
||||
//! ends beside more of the fixed face, the rule's bend relief is cut there. A wall that stands on
|
||||
//! part of an edge of the fixed face (its outside in one plane with the plate's edge, which runs
|
||||
//! on past it a strip no taller than the bend's setback) bends only where it stands: the strip is
|
||||
//! the fixed face's own edge.
|
||||
//!
|
||||
//! 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, a wall on part of an edge of a face other than the fixed one.
|
||||
|
||||
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 +67,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 +102,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 +271,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`.
|
||||
@@ -188,6 +508,96 @@ fn inset(pts: &[Vec2], by: &[f64]) -> Result<(Vec<Vec2>, Vec<Option<usize>>)> {
|
||||
Ok((out, from))
|
||||
}
|
||||
|
||||
/// A wall standing on part of an edge of the fixed face: where the outline of `wall`, the face
|
||||
/// across a bend from the fixed face `base` (`ids`: the two faces), leaves the bend's edge up a
|
||||
/// strip no taller than the bend's setback `by`, runs back along the edge and climbs the wall's
|
||||
/// end, the strip is the fixed face's own edge (a plate's edge face in one plane with the outside
|
||||
/// of a wall that stands on part of it), which the setback would use up. It is cut off the wall,
|
||||
/// whose bend's edge then ends where the line of the wall's end meets it, and the fixed face's
|
||||
/// edge is split there: the pieces beside the wall are no bend. `bend` says whether a face is a
|
||||
/// bend of the wall. Any other wall is left as it is.
|
||||
fn end_at_wall(base: &mut Face, wall: &mut Face, ids: (usize, usize), by: f64, bend: &dyn Fn(usize) -> bool, tol: f64) {
|
||||
let (m, n) = (wall.pts.len(), base.pts.len());
|
||||
let at_wall: Vec<usize> = (0..m).filter(|i| wall.across.get(*i).copied().flatten() == Some(ids.0)).collect();
|
||||
let at_base: Vec<usize> = (0..n).filter(|i| base.across.get(*i).copied().flatten() == Some(ids.1)).collect();
|
||||
let (&[k], &[i]) = (at_wall.as_slice(), at_base.as_slice()) else { return };
|
||||
if m < 5 {
|
||||
return;
|
||||
}
|
||||
// Corner `j` and edge `j` (from corner `j`) counted from the bend's edge on.
|
||||
let pt = |j: usize| wall.pts.get((k + j) % m).copied().unwrap_or_default();
|
||||
let (p, q) = (pt(0), pt(1));
|
||||
let Some(d) = (q - p).normalized() else { return };
|
||||
// Up the wall from the bend's edge (the outline runs counter-clockwise about the normal), and
|
||||
// along the edge.
|
||||
let up = |x: Vec3| (x - p).dot(wall.n.cross(d));
|
||||
let along = |x: Vec3| (x - p).dot(d);
|
||||
let free = |j: usize| {
|
||||
let j = (k + j) % m;
|
||||
wall.round.get(j).is_some_and(Option::is_none) && wall.across.get(j).copied().flatten().is_none_or(|g| !bend(g))
|
||||
};
|
||||
let low = |x: Vec3| up(x) > tol && up(x) <= by + tol;
|
||||
// A strip from an end of the bend's edge up to `x1`, back along the edge (`back`: the way it
|
||||
// runs) to `x2`, then the wall's end up to `x3`: where the line of that end meets the edge.
|
||||
let foot = |x1: Vec3, x2: Vec3, x3: Vec3, back: f64| {
|
||||
let rise = up(x3) - up(x2);
|
||||
(low(x1) && low(x2) && (x2 - x1).dot(d) * back > tol && rise > tol).then(|| x2 - (x3 - x2) * (up(x2) / rise))
|
||||
};
|
||||
// At the edge's end the strip is edges 1 and 2, the wall's end edge 3; at its start the strip
|
||||
// is edges m - 1 and m - 2, the wall's end edge m - 3.
|
||||
let ok = |js: [usize; 3]| js.iter().all(|j| free(*j));
|
||||
let end = if ok([1, 2, 3]) { foot(pt(2), pt(3), pt(4), -1.0) } else { None };
|
||||
let start = if ok([m - 1, m - 2, m - 3]) { foot(pt(m - 1), pt(m - 2), pt(m - 3), 1.0) } else { None };
|
||||
let (p1, q1) = (start.unwrap_or(p), end.unwrap_or(q));
|
||||
let len = along(q);
|
||||
let inside = |x: Option<Vec3>| x.is_none_or(|x| along(x) > tol && along(x) < len - tol);
|
||||
if (start.is_none() && end.is_none())
|
||||
|| !inside(start)
|
||||
|| !inside(end)
|
||||
|| along(q1) - along(p1) <= tol
|
||||
|| (start.is_some() && end.is_some() && m < 7)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The wall: the strips' corners and edges go, the bend's edge runs from `p1` to `q1`.
|
||||
let (mut corners_out, mut edges_out) = (Vec::new(), Vec::new());
|
||||
if end.is_some() {
|
||||
corners_out.extend([2, 3]);
|
||||
edges_out.extend([1, 2]);
|
||||
}
|
||||
if start.is_some() {
|
||||
corners_out.extend([m - 1, m - 2]);
|
||||
edges_out.extend([m - 1, m - 2]);
|
||||
}
|
||||
if let Some(x) = wall.pts.get_mut(k) {
|
||||
*x = p1;
|
||||
}
|
||||
if let Some(x) = wall.pts.get_mut((k + 1) % m) {
|
||||
*x = q1;
|
||||
}
|
||||
let kept = |j: usize, out: &[usize]| !out.contains(&((j + m - k) % m));
|
||||
wall.pts = wall.pts.iter().enumerate().filter(|(j, _)| kept(*j, &corners_out)).map(|(_, x)| *x).collect();
|
||||
wall.across = wall.across.iter().enumerate().filter(|(j, _)| kept(*j, &edges_out)).map(|(_, x)| *x).collect();
|
||||
wall.round = wall.round.iter().enumerate().filter(|(j, _)| kept(*j, &edges_out)).map(|(_, x)| *x).collect();
|
||||
// The fixed face's edge: the bend from `p1` to `q1` (it runs the other way along it), no bend
|
||||
// beside it.
|
||||
let (Some(&a), Some(&b)) = (base.pts.get(i), base.pts.get((i + 1) % n)) else { return };
|
||||
let from_a = |x: Vec3| (x - a).dot(b - a);
|
||||
let (x, y) = if from_a(p1) < from_a(q1) { (p1, q1) } else { (q1, p1) };
|
||||
let mut pieces: Vec<(Vec3, Option<usize>)> = Vec::with_capacity(3);
|
||||
if x.dist(a) > tol {
|
||||
pieces.push((a, None));
|
||||
}
|
||||
pieces.push((if x.dist(a) > tol { x } else { a }, Some(ids.1)));
|
||||
if y.dist(b) > tol {
|
||||
pieces.push((y, None));
|
||||
}
|
||||
let count = pieces.len();
|
||||
base.pts.splice(i..=i, pieces.iter().map(|(x, _)| *x));
|
||||
base.across.splice(i..=i, pieces.iter().map(|(_, g)| *g));
|
||||
base.round.splice(i..=i, std::iter::repeat_n(None, count));
|
||||
}
|
||||
|
||||
/// Give flange `i` its panel: `outline` in flat coordinates, counter-clockwise, its edge at the
|
||||
/// bend first trimmed to the end of the bend zone.
|
||||
fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) {
|
||||
@@ -210,20 +620,60 @@ 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.
|
||||
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<SheetBody> {
|
||||
/// 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.
|
||||
/// Returns the sheet and a note for every corner or end of a bend not finished as the rule says.
|
||||
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<(SheetBody, Vec<String>)> {
|
||||
if bends.is_empty() || bends.len() > MAX_BENDS {
|
||||
return err(format!("pick 1…{MAX_BENDS} bend edges"));
|
||||
}
|
||||
@@ -240,10 +690,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 +712,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 +764,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 +789,46 @@ 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()))?;
|
||||
// A wall on part of an edge of the fixed face bends only where it stands (a bend picked as a
|
||||
// chamfer or a cylinder is taken as it is).
|
||||
let walls: Vec<(usize, f64)> = panels.iter().filter(|(_, p)| p.parent == Some(f0)).map(|(g, p)| (*g, p.setback)).collect();
|
||||
for (g, by) in walls {
|
||||
if chamfers.iter().any(|c| (c.a, c.b) == (f0, g) || (c.a, c.b) == (g, f0)) {
|
||||
continue;
|
||||
}
|
||||
let kids: Vec<usize> = panels.iter().filter(|(_, p)| p.parent == Some(g)).map(|(h, _)| *h).collect();
|
||||
let Some(mut wall) = panels.remove(&g) else { continue };
|
||||
if let Some(base) = panels.get_mut(&f0) {
|
||||
end_at_wall(&mut base.face, &mut wall.face, (f0, g), by, &|h| h == f0 || kids.contains(&h), tol);
|
||||
}
|
||||
panels.insert(g, wall);
|
||||
}
|
||||
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 +854,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,13 +978,32 @@ 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),
|
||||
});
|
||||
queue.push((g, Some(sh.flanges.len() - 1)));
|
||||
}
|
||||
}
|
||||
Ok(sh)
|
||||
// The bends on the fixed face finished as an edge flange feature finishes its own
|
||||
// (`SheetBody::add_flanges_each`): the rule's bend relief where one ends beside more of the
|
||||
// fixed face; where two meet at a corner of it, their walls closed with the rule's gap (which
|
||||
// the cuts above already are) and the rule's corner relief.
|
||||
let made: Vec<usize> = (0..sh.flanges.len()).collect();
|
||||
let mut notes = Vec::new();
|
||||
for &i in &made {
|
||||
notes.extend(sh.relieve_bend_ends(i, (false, false)));
|
||||
}
|
||||
let corners = sh.corners(&made);
|
||||
let mut closed = Vec::with_capacity(corners.len());
|
||||
for k in &corners {
|
||||
match sh.close_corner(k) {
|
||||
Ok(()) => closed.push(*k),
|
||||
Err(why) => notes.push(format!("the corner at {} stays cut open: {why}", sh.shown(k.c))),
|
||||
}
|
||||
}
|
||||
notes.extend(sh.relieve_corners(&corners, &closed));
|
||||
Ok((sh, notes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+4
-35
@@ -29,9 +29,11 @@ fn plate_doc() -> (Document, u64) {
|
||||
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: s,
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "20".into(),
|
||||
direction: Direction::Positive,
|
||||
distance2: None,
|
||||
@@ -120,9 +122,11 @@ fn box_fillet_cut_timeline() {
|
||||
let s2 = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
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,
|
||||
@@ -342,41 +346,6 @@ fn input_sketches_cover_points_and_patches() {
|
||||
assert_eq!(pipe.input_sketches(), vec![72]);
|
||||
}
|
||||
|
||||
/// Guard for `FeatureKind::input_sketches`: a variant with a field that refers to a sketch
|
||||
/// (a field named `*sketch*` holding an id, sketch points or loft sections) must not sit in the
|
||||
/// arm that returns no sketches. New variants are caught by the compiler (the match has no
|
||||
/// wildcard); this catches a sketch field added to an existing variant.
|
||||
#[test]
|
||||
fn input_sketches_lists_every_variant_with_a_sketch_field() {
|
||||
let src = include_str!("document.rs");
|
||||
let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap();
|
||||
let mut with_sketch = Vec::new();
|
||||
let mut variant = "";
|
||||
for line in body.lines() {
|
||||
let t = line.trim();
|
||||
if line.starts_with(" ") && !line.starts_with(" ") && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
|
||||
variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
|
||||
let refers = (name.contains("sketch") && (ty.contains("u64") || ty.contains("SketchPoints")))
|
||||
|| ty.contains("SketchPoints")
|
||||
|| ty.contains("LoftSection");
|
||||
if refers && !line.trim_start().starts_with("//") && !with_sketch.contains(&variant) {
|
||||
with_sketch.push(variant);
|
||||
}
|
||||
}
|
||||
assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}");
|
||||
let f = src.split("pub fn input_sketches(&self)").nth(1).unwrap();
|
||||
let empty_arm = f.split("=> Vec::new()").next().and_then(|s| s.rsplit("=> ").next()).unwrap();
|
||||
for v in with_sketch {
|
||||
assert!(
|
||||
!empty_arm.contains(&format!("K::{v} {{")),
|
||||
"FeatureKind::{v} has a sketch field but input_sketches says it uses no sketch: deleting the sketch would leave it broken"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() {
|
||||
let body = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap();
|
||||
|
||||
@@ -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!();
|
||||
}
|
||||
@@ -216,9 +216,23 @@ fn ls_extruded_up_and_down_from_one_grid_join() {
|
||||
assert!((v - 6000.0).abs() < 1e-6, "{v}");
|
||||
}
|
||||
|
||||
/// This process's CPU time (all threads) and peak resident memory, where the system says
|
||||
/// (Linux: /proc/self); `None` elsewhere.
|
||||
fn cpu_and_peak_memory() -> Option<(std::time::Duration, u64)> {
|
||||
let stat = std::fs::read_to_string("/proc/self/stat").ok()?;
|
||||
// utime and stime, in clock ticks (100 a second on Linux), after the command's name.
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: u64 = fields.get(11)?.parse::<u64>().ok()? + fields.get(12)?.parse::<u64>().ok()?;
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let kb: u64 = status.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
|
||||
Some((std::time::Duration::from_millis(ticks * 10), kb * 1024))
|
||||
}
|
||||
|
||||
/// Press/pull on the sensor enclosure's lid top: joining the pulled face's prism imprints the
|
||||
/// contact (#825), and boxing the lid's faces by dividing their curved edges down to the
|
||||
/// contact tolerance ran away (over 24 GB and many minutes). It finishes in seconds.
|
||||
/// contact tolerance ran away (over 24 GB and many minutes). It takes about 9 s of CPU here and
|
||||
/// no memory beyond the design's. The budgets are work, not wall time, which a loaded runner
|
||||
/// stretches (61-87 s for the same work on CI).
|
||||
#[test]
|
||||
fn press_pull_on_the_enclosure_lid_is_prompt() {
|
||||
let mut s = Session::default();
|
||||
@@ -226,9 +240,30 @@ fn press_pull_on_the_enclosure_lid_is_prompt() {
|
||||
s.run_script(&script).unwrap();
|
||||
let total = |s: &Session| -> f64 { s.model.state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum() };
|
||||
let before = total(&s);
|
||||
let t = std::time::Instant::now();
|
||||
let start = cpu_and_peak_memory();
|
||||
s.execute("solid.press_pull", &json!({"face": [-12.0, 27.4, 23.0], "distance": 5.0})).unwrap();
|
||||
// A few seconds here; the bound leaves room for a loaded CI runner.
|
||||
assert!(t.elapsed().as_secs() < 60, "took {:?}", t.elapsed());
|
||||
if let (Some((cpu0, peak0)), Some((cpu1, peak1))) = (start, cpu_and_peak_memory()) {
|
||||
let grew = peak1.saturating_sub(peak0);
|
||||
assert!(grew < 1 << 30, "the press/pull grew the peak memory by {} MB", grew >> 20);
|
||||
// Other tests in this process (cargo test) count too, so the bound is generous.
|
||||
assert!(cpu1 - cpu0 < std::time::Duration::from_secs(300), "the press/pull took {:?} of CPU", cpu1 - cpu0);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -38,8 +38,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
|
||||
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
|
||||
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
|
||||
or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design; not an undo step",
|
||||
"visible: bool; items?: [sketch id or name | body name | construction name | joint name | O|X|Y|Z|XY|XZ|YZ]; \
|
||||
or joints?: [joint id or name] (their glyphs; they still constrain and move parts); \
|
||||
or folder?: bodies|sketches|construction|origin|joints (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design; not an undo step",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -170,6 +171,8 @@ enum Shown {
|
||||
Body(String),
|
||||
/// Origin items and construction geometry, by name.
|
||||
Origin(String),
|
||||
/// An assembly joint's glyph, by joint id.
|
||||
Joint(u64),
|
||||
}
|
||||
|
||||
fn is_construction(k: &FeatureKind) -> bool {
|
||||
@@ -188,6 +191,9 @@ fn shown_item(s: &Session, key: &str) -> Option<Shown> {
|
||||
if s.world_state().bodies.iter().any(|b| b.name == key) {
|
||||
return Some(Shown::Body(key.to_string()));
|
||||
}
|
||||
if let Some(j) = s.doc.assembly.joints.iter().find(|j| j.name == key) {
|
||||
return Some(Shown::Joint(j.id));
|
||||
}
|
||||
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
|
||||
}
|
||||
|
||||
@@ -255,12 +261,24 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility}));
|
||||
}
|
||||
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
|
||||
_ if p.get("joints").is_some() => {
|
||||
if p.get("items").is_some() || p.get("folder").is_some() {
|
||||
return Err(bad(cmd, "give `joints`, `items` or `folder`, not several"));
|
||||
}
|
||||
let list = p
|
||||
.get("joints")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|a| !a.is_empty() && a.len() <= 10_000)
|
||||
.ok_or_else(|| bad(cmd, "`joints` must list 1..10000 joint ids or names"))?;
|
||||
list.iter().map(|j| super::joints::joint_id(s, Some(j), cmd).map(Shown::Joint)).collect::<Result<_>>()?
|
||||
}
|
||||
(Some(_), Some(_)) => return Err(bad(cmd, "give `items` or `folder`, not both")),
|
||||
(Some("bodies"), None) => s.world_state().bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
|
||||
(Some("sketches"), None) => s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)).collect(),
|
||||
(Some("construction"), None) => s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())).collect(),
|
||||
(Some("origin"), None) => ORIGIN.iter().map(|o| Shown::Origin(o.to_string())).collect(),
|
||||
(Some(f), None) => return Err(bad(cmd, format!("unknown folder `{f}` (bodies, sketches, construction, origin)"))),
|
||||
(Some("joints"), None) => s.doc.assembly.joints.iter().map(|j| Shown::Joint(j.id)).collect(),
|
||||
(Some(f), None) => return Err(bad(cmd, format!("unknown folder `{f}` (bodies, sketches, construction, origin, joints)"))),
|
||||
(None, Some(_)) => {
|
||||
let keys = keys(p, "items", cmd)?;
|
||||
if keys.is_empty() {
|
||||
@@ -268,7 +286,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Every item is found before anything changes.
|
||||
keys.iter()
|
||||
.map(|k| shown_item(s, k).ok_or_else(|| bad(cmd, format!("no sketch, body or construction item `{k}`"))))
|
||||
.map(|k| shown_item(s, k).ok_or_else(|| bad(cmd, format!("no sketch, body, construction item or joint `{k}`"))))
|
||||
.collect::<Result<_>>()?
|
||||
}
|
||||
(None, None) => return Err(bad(cmd, "`items` or `folder` is required")),
|
||||
@@ -296,6 +314,12 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v.hidden_origin.push(n.clone());
|
||||
}
|
||||
}
|
||||
Shown::Joint(id) => {
|
||||
v.hidden_joints.retain(|x| x != id);
|
||||
if !visible {
|
||||
v.hidden_joints.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
@@ -489,22 +513,59 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("another body is called `{name}`")));
|
||||
}
|
||||
// The body is the k-th new body of its feature; its siblings keep the names they have now
|
||||
// (unpinned pattern copies would otherwise be renumbered into the freed name, #265).
|
||||
// (unpinned pattern copies would otherwise be renumbered into the freed name, #265). That
|
||||
// includes the siblings whose stored name is shared: the solids of a STEP file with one label
|
||||
// are stored as `X`, `X`, `X` and built as `X`, `X (2)`, `X (3)`, so the stored names are set
|
||||
// to the numbered ones, or `X (3)` would turn into `X (2)` once the second solid is renamed.
|
||||
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();
|
||||
f.body_names.push(siblings.get(i).cloned().unwrap_or_default());
|
||||
}
|
||||
for (slot, current) in f.body_names.iter_mut().zip(&siblings) {
|
||||
slot.clone_from(current);
|
||||
}
|
||||
if let Some(slot) = f.body_names.get_mut(k) {
|
||||
*slot = name.to_string();
|
||||
}
|
||||
// Later features refer to bodies by name.
|
||||
let mut failed = None;
|
||||
for f in d.features.iter_mut().filter(|f| f.id != fid) {
|
||||
// The bodies a join/cut/intersect may change (a primitive's `targets`) are kept beside
|
||||
// the feature's kind, not in it (#1108).
|
||||
for b in f.participants.iter_mut().filter(|b| **b == old) {
|
||||
*b = name.to_string();
|
||||
}
|
||||
if let FeatureKind::Sketch { plane, .. } = &mut f.kind {
|
||||
solidcraft_doc::sheet::rename_plane_sheet_body(plane, &old, name);
|
||||
}
|
||||
@@ -705,6 +766,59 @@ mod tests {
|
||||
assert_eq!(names(&s)[1], b);
|
||||
}
|
||||
|
||||
/// #1108: a primitive's `targets` are its participants, kept beside its kind; a rename
|
||||
/// follows them, as it follows the names inside the kind (a combine's target and tools).
|
||||
#[test]
|
||||
fn renamed_body_keeps_the_primitives_that_target_it() {
|
||||
let mut s = Session::default();
|
||||
let total = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "name": "Block", "body_name": "A"}));
|
||||
run(&mut s, "solid.cylinder", json!({"diameter": 5, "height": 12, "base": [5, 5, -1], "operation": "cut", "targets": ["A"], "name": "Hole"}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0], "operation": "new", "body_name": "Tab"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "A", "tools": ["Tab"], "operation": "join", "name": "Joined"}));
|
||||
let want = total(&mut s);
|
||||
run(&mut s, "body.rename", json!({"body": "A", "name": "B"}));
|
||||
assert!(
|
||||
s.model.results.iter().all(|r| r.error.is_none()),
|
||||
"{:?}",
|
||||
s.model.results.iter().filter_map(|r| r.error.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(s.doc.find_feature("Hole").unwrap().participants, vec!["B".to_string()]);
|
||||
assert!((total(&mut s) - want).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
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();
|
||||
|
||||
@@ -104,17 +104,21 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let outputs = if f.body_names.is_empty() {
|
||||
s.model
|
||||
.result(f.id)
|
||||
.map(|result| result.state.bodies.iter().filter(|body| body.feature == f.id).map(|body| body.name.clone()).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
f.body_names.clone()
|
||||
};
|
||||
let built: Vec<String> = s
|
||||
.model
|
||||
.result(f.id)
|
||||
.map(|result| result.state.bodies.iter().filter(|body| body.feature == f.id).map(|body| body.name.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
let outputs = if f.body_names.is_empty() { built.clone() } else { f.body_names.clone() };
|
||||
// A later feature names a body as the model shows it, and that is not always the name stored
|
||||
// on the feature: solids that share a label are stored as `X`, `X`, `X` and shown as `X`,
|
||||
// `X (2)`, `X (3)`, and a second import of a file shows `Aa (2)` for the stored `Aa`. The
|
||||
// copies are found by the shown names; the stored ones only name the copies.
|
||||
let shown = if built.len() == outputs.len() { built } else { outputs.clone() };
|
||||
let copied_names: Vec<_> = outputs
|
||||
.iter()
|
||||
.map(|original| {
|
||||
.zip(&shown)
|
||||
.map(|(original, shown)| {
|
||||
let base = format!("{original} (copy)");
|
||||
let mut name = base.clone();
|
||||
let mut suffix = 2usize;
|
||||
@@ -122,7 +126,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
name = format!("{base} {suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
bodies.insert(original.clone(), name.clone());
|
||||
bodies.insert(shown.clone(), name.clone());
|
||||
name
|
||||
})
|
||||
.collect();
|
||||
@@ -146,10 +150,18 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
m[3][2] = t.z;
|
||||
kind.transform_geometry(&m);
|
||||
}
|
||||
// A cylinder Emboss finds its face only by the persistent name recorded when it was made
|
||||
// (#1035): the copy keeps that name, pointed at the pasted features and bodies.
|
||||
let face_names: Vec<String> = if matches!(kind, FeatureKind::Emboss { face: Some(_), .. }) {
|
||||
f.face_names.iter().map(|n| solidcraft_doc::remap_topology(n, &sketches, &bodies)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let id = doc.add_feature(kind, None)?;
|
||||
if let Some(nf) = doc.feature_mut(id) {
|
||||
nf.component = comp;
|
||||
nf.body_names = copied_names;
|
||||
nf.face_names = face_names;
|
||||
}
|
||||
let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default();
|
||||
sketches.insert(f.id, id);
|
||||
@@ -167,7 +179,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -233,6 +245,173 @@ mod tests {
|
||||
assert!(solidcraft_doc::Document::from_json(&s.doc.to_json()).is_ok(), "pasting inside a folder must leave a reopenable design");
|
||||
}
|
||||
|
||||
/// A STEP file of three boxes at x = 0, 30 and 60. With `shared` the three solids carry one
|
||||
/// label (`X`), as the parts of many assembly files do; otherwise each has its own.
|
||||
fn three_box_step(tag: &str, shared: bool) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-paste-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::default();
|
||||
for (i, label) in ["Aa", "Bb", "Cc"].iter().enumerate() {
|
||||
let corner = [30.0 * i as f64, 0.0, 0.0];
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": corner, "operation": "new_body", "body_name": label}));
|
||||
}
|
||||
let path = dir.join("Three.step");
|
||||
run(&mut s, "file.export", json!({"path": path.to_string_lossy()}));
|
||||
if shared {
|
||||
let mut text = std::fs::read_to_string(&path).unwrap();
|
||||
for label in ["Aa", "Bb", "Cc"] {
|
||||
assert!(text.contains(&format!("MANIFOLD_SOLID_BREP('{label}'")), "the file names its solids: {label}");
|
||||
text = text.replace(&format!("MANIFOLD_SOLID_BREP('{label}'"), "MANIFOLD_SOLID_BREP('X'");
|
||||
}
|
||||
std::fs::write(&path, text).unwrap();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// An OBJ file of three 10 mm cubes at x = 0, 30 and 60, every object called `X`.
|
||||
fn three_cube_obj(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-paste-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let corners = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
|
||||
let faces =
|
||||
[[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [3, 7, 6], [3, 6, 2], [0, 4, 7], [0, 7, 3], [1, 2, 6], [1, 6, 5]];
|
||||
let mut text = String::new();
|
||||
for i in 0..3 {
|
||||
text += "o X\n";
|
||||
for c in corners {
|
||||
text += &format!("v {} {} {}\n", 30 * i + 10 * c[0], 10 * c[1], 10 * c[2]);
|
||||
}
|
||||
for f in faces {
|
||||
text += &format!("f {} {} {}\n", 8 * i + f[0] + 1, 8 * i + f[1] + 1, 8 * i + f[2] + 1);
|
||||
}
|
||||
}
|
||||
let path = dir.join("Three.obj");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn body_names(s: &Session) -> Vec<String> {
|
||||
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Where every body is: the lowest corner of its bounding box, rounded, in model order.
|
||||
fn corners(s: &mut Session) -> Vec<Vec<f64>> {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
let bodies = m["bodies"].as_array().cloned().unwrap_or_default();
|
||||
bodies
|
||||
.iter()
|
||||
.map(|b| b["bbox"]["min"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64().map(f64::round)).collect()).unwrap_or_default())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Lifts the first of the three bodies of the import by 20 and the second by 50 (two features),
|
||||
/// copies the import with both and pastes them: the copies must be lifted, the originals once.
|
||||
fn paste_import_with_two_lifts(s: &mut Session, import: &Value, bodies: [&str; 3]) {
|
||||
run(s, "solid.move", json!({"bodies": [bodies[0]], "translate": [0, 0, 20], "name": "Lift first"}));
|
||||
run(s, "solid.move", json!({"bodies": [bodies[1]], "translate": [0, 0, 50], "name": "Lift second"}));
|
||||
run(s, "timeline.copy", json!({"features": [import, "Lift first", "Lift second"]}));
|
||||
let r = run(s, "timeline.paste", json!({}));
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
}
|
||||
|
||||
const LIFTED: [[f64; 3]; 3] = [[0.0, 0.0, 20.0], [30.0, 0.0, 50.0], [60.0, 0.0, 0.0]];
|
||||
|
||||
/// A control: three solids with labels of their own. The pasted features act on the pasted bodies.
|
||||
#[test]
|
||||
fn pasting_an_import_moves_the_copies_of_the_bodies_its_features_named() {
|
||||
let path = three_box_step("distinct", false);
|
||||
let mut s = Session::default();
|
||||
let import = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
|
||||
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc"]);
|
||||
paste_import_with_two_lifts(&mut s, &import, ["Aa", "Bb", "Cc"]);
|
||||
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (copy)", "Bb (copy)", "Cc (copy)"]);
|
||||
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&LIFTED).map(|c| c.to_vec()).collect();
|
||||
assert_eq!(corners(&mut s), at);
|
||||
}
|
||||
|
||||
/// Solids of a STEP file that share one label are built as `X`, `X (2)`, `X (3)`, and the features
|
||||
/// made on them name them so. The pasted features act on the copies of those bodies, each on its
|
||||
/// own: `X (2)` is not left on the original, and `X` does not go to the last copy.
|
||||
#[test]
|
||||
fn pasting_an_import_with_one_label_on_all_solids_moves_the_copy_of_each_body() {
|
||||
let path = three_box_step("shared", true);
|
||||
let mut s = Session::default();
|
||||
let import = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
|
||||
assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]);
|
||||
paste_import_with_two_lifts(&mut s, &import, ["X", "X (2)", "X (3)"]);
|
||||
let names = body_names(&s);
|
||||
assert_eq!(names.len(), 6, "{names:?}");
|
||||
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&LIFTED).map(|c| c.to_vec()).collect();
|
||||
assert_eq!(corners(&mut s), at, "{names:?}");
|
||||
// The copies keep their names and their places when the design is saved and opened again.
|
||||
let design = path.with_file_name("Three.solidcraft");
|
||||
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
|
||||
let mut r = Session::default();
|
||||
run(&mut r, "doc.open", json!({"path": design.to_string_lossy()}));
|
||||
assert_eq!(body_names(&r), names);
|
||||
assert_eq!(corners(&mut r), at);
|
||||
}
|
||||
|
||||
/// The same for the objects of an OBJ file (3MF and STL files give the same kind of import).
|
||||
#[test]
|
||||
fn pasting_a_mesh_import_with_one_label_on_all_objects_moves_the_copy_of_each_body() {
|
||||
let path = three_cube_obj("shared-obj");
|
||||
let mut s = Session::default();
|
||||
let import = run(&mut s, "file.insert_mesh", json!({"path": path.to_string_lossy()}))["feature"].clone();
|
||||
assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]);
|
||||
paste_import_with_two_lifts(&mut s, &import, ["X", "X (2)", "X (3)"]);
|
||||
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&LIFTED).map(|c| c.to_vec()).collect();
|
||||
assert_eq!(corners(&mut s), at, "{:?}", body_names(&s));
|
||||
}
|
||||
|
||||
/// No shared label is needed: a second import of the same file builds `Aa (2)`, `Bb (2)`, `Cc (2)`
|
||||
/// from the labels `Aa`, `Bb`, `Cc`, and the features made on those bodies name them so.
|
||||
#[test]
|
||||
fn pasting_a_second_import_of_a_file_moves_the_copies_of_its_bodies() {
|
||||
let path = three_box_step("twice", false);
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}));
|
||||
let second = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
|
||||
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
|
||||
paste_import_with_two_lifts(&mut s, &second, ["Aa (2)", "Bb (2)", "Cc (2)"]);
|
||||
let plain = [[0.0, 0.0, 0.0], [30.0, 0.0, 0.0], [60.0, 0.0, 0.0]];
|
||||
let at: Vec<Vec<f64>> = plain.iter().chain(&LIFTED).chain(&LIFTED).map(|c| c.to_vec()).collect();
|
||||
assert_eq!(corners(&mut s), at, "{:?}", body_names(&s));
|
||||
}
|
||||
|
||||
/// A cylinder Emboss finds its face by the persistent name it recorded when it was made. The
|
||||
/// pasted Emboss keeps that name, moved onto the pasted cylinder, so the copy gets its own
|
||||
/// badge, with or without a translate (#1035).
|
||||
#[test]
|
||||
fn pasted_cylinder_emboss_badges_the_pasted_cylinder() {
|
||||
for translate in [Some(json!([50, 0, 0])), None] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": "10 mm", "height": "20 mm", "body_name": "Sleeve", "name": "Tube"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 10, 0], "x_dir": [-1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-2, 5], "p1": [2, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch": "Badge", "face": [0, 10, 7.5], "depth": 1, "targets": ["Sleeve"], "name": "Mark"}));
|
||||
let one = volume(&mut s);
|
||||
let plain = std::f64::consts::PI * 100.0 * 20.0;
|
||||
assert!(one > plain + 1.0, "the badge adds material: {one}");
|
||||
run(&mut s, "timeline.copy", json!({"features": ["Tube", "Badge", "Mark"]}));
|
||||
let p = translate.clone().map_or(json!({}), |t| json!({"translate": t}));
|
||||
let r = run(&mut s, "timeline.paste", p);
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{translate:?}: {r}");
|
||||
let mark = s.doc.find_feature(r["pasted"][2]["name"].as_str().unwrap_or_default()).cloned().unwrap();
|
||||
let tube = r["pasted"][0]["id"].as_u64().unwrap();
|
||||
assert!(mark.face_names.first().is_some_and(|n| n.starts_with(&format!("F{tube}:"))), "{:?}", mark.face_names);
|
||||
assert!((volume(&mut s) - 2.0 * one).abs() < 1e-6, "{translate:?}: both cylinders carry a badge");
|
||||
if translate.is_some() {
|
||||
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Sleeve (copy)"]}));
|
||||
let body = &m["bodies"][0];
|
||||
let x = body["center_of_mass"][0].as_f64().unwrap_or(f64::NAN);
|
||||
let y = body["bbox"]["max"][1].as_f64().unwrap_or(f64::NAN);
|
||||
assert!((x - 50.0).abs() < 1e-3 && (y - 11.0).abs() < 1e-6, "the copy's badge sits on the moved cylinder: {m}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_fillet_rounds_the_copied_body_it_names() {
|
||||
let mut s = Session::default();
|
||||
@@ -253,4 +432,96 @@ mod tests {
|
||||
assert_eq!(m["body_count"], 2, "{m}");
|
||||
assert!((volume(&mut s) - 2.0 * one).abs() < 1e-6, "both blocks are rounded: {m}");
|
||||
}
|
||||
|
||||
/// One body's measures (bounding box, volume).
|
||||
fn measures(s: &mut Session, body: &str) -> serde_json::Value {
|
||||
run(s, "inspect.measure", json!({"bodies": [body]}))["bodies"][0].clone()
|
||||
}
|
||||
|
||||
/// Pasting with a translate moves an extrude's `to` and `from` picks with it: the pasted posts
|
||||
/// run to the pasted roof and from the pasted shelf, not to and from the originals.
|
||||
#[test]
|
||||
fn a_pasted_extrude_runs_to_and_from_the_pasted_faces() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 2, "corner": [0, 0, 20], "name": "Roof", "body_name": "Roof"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 2, "corner": [0, 0, 10], "name": "Shelf", "body_name": "Shelf"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5, "to": [7, 7, 20], "operation": "new", "name": "Up", "body_name": "Up"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "T"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [25, 25], "p1": [30, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "T", "distance": 5, "from": [27, 27, 12], "operation": "new", "name": "On", "body_name": "On"}),
|
||||
);
|
||||
run(&mut s, "timeline.copy", json!({"features": ["Roof", "Shelf", "S", "Up", "T", "On"]}));
|
||||
let r = run(&mut s, "timeline.paste", json!({"translate": [100, 0, 10]}));
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let up = measures(&mut s, "Up (copy)");
|
||||
assert!((up["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 10.0).abs() < 1e-6, "{up}");
|
||||
assert!((up["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 30.0).abs() < 1e-6, "it ends on the pasted roof: {up}");
|
||||
let on = measures(&mut s, "On (copy)");
|
||||
assert!((on["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 22.0).abs() < 1e-6, "it starts on the pasted shelf: {on}");
|
||||
assert!((on["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 27.0).abs() < 1e-6, "{on}");
|
||||
}
|
||||
|
||||
/// Pasting with a translate moves a hole's `to` pick with it: the pasted through hole still goes
|
||||
/// through, rather than stopping at the original's depth.
|
||||
#[test]
|
||||
fn a_pasted_hole_drills_to_the_moved_point() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 10, "name": "Block", "body_name": "Block"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [20, 20, 10], "direction": [0, 0, -1], "diameter": 10, "to": [30, 30, -2], "name": "Drill"}));
|
||||
run(&mut s, "timeline.copy", json!({"features": ["Block", "Drill"]}));
|
||||
let r = run(&mut s, "timeline.paste", json!({"translate": [60, 0, -6]}));
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let (block, copy) = (measures(&mut s, "Block"), measures(&mut s, "Block (copy)"));
|
||||
let v = |m: &serde_json::Value| m["volume_mm3"].as_f64().unwrap_or(f64::NAN);
|
||||
assert!((v(&block) - (16000.0 - std::f64::consts::PI * 25.0 * 10.0)).abs() < 1.0, "a through hole: {block}");
|
||||
assert!((v(©) - v(&block)).abs() < 1e-6, "the pasted hole goes through too: {copy}");
|
||||
}
|
||||
|
||||
/// Pasting with a translate moves a revolve's `to` pick with it: the pasted turn ends at the same
|
||||
/// angle about its moved axis.
|
||||
#[test]
|
||||
fn a_pasted_revolve_turns_to_the_moved_point() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "S"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "S", "axis": "y", "to": [0, 12, 2], "name": "Turn", "body_name": "Arc"}));
|
||||
run(&mut s, "timeline.copy", json!({"features": ["S", "Turn"]}));
|
||||
let r = run(&mut s, "timeline.paste", json!({"translate": [50, 0, 0]}));
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let (arc, copy) = (measures(&mut s, "Arc"), measures(&mut s, "Arc (copy)"));
|
||||
let v = |m: &serde_json::Value| m["volume_mm3"].as_f64().unwrap_or(f64::NAN);
|
||||
// A quarter turn of a 5 × 5 section whose centre is 12.5 from the axis.
|
||||
let quarter = 25.0 * std::f64::consts::FRAC_PI_2 * 12.5;
|
||||
assert!((v(&arc) - quarter).abs() < quarter * 1e-3, "{arc}");
|
||||
assert!((v(©) - v(&arc)).abs() < 1e-6, "the pasted arc turns a quarter too: {copy}");
|
||||
}
|
||||
|
||||
/// A pasted coil that joins a body made by an earlier copy joins that copy, not the original.
|
||||
#[test]
|
||||
fn a_pasted_coil_joins_the_pasted_body() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 5, "name": "Base", "body_name": "Plate"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.coil",
|
||||
json!({"diameter": 16, "pitch": 6, "revolutions": 3, "section_size": 2, "base": [15, 15, 5], "operation": "join", "targets": ["Plate"], "name": "Spring"}),
|
||||
);
|
||||
run(&mut s, "timeline.copy", json!({"features": ["Base", "Spring"]}));
|
||||
let r = run(&mut s, "timeline.paste", json!({"translate": [50, 0, 0]}));
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let spring = r["pasted"][1]["name"].as_str().unwrap_or_default().to_string();
|
||||
let def = run(&mut s, "timeline.inspect", json!({"feature": spring}));
|
||||
assert_eq!(def["definition"]["targets"], json!(["Plate (copy)"]), "{def}");
|
||||
let st = s.world_state();
|
||||
let v = |name: &str| solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume;
|
||||
assert!((v("Plate") - v("Plate (copy)")).abs() < 1e-6, "each plate carries one spring: {} vs {}", v("Plate"), v("Plate (copy)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -94,6 +94,24 @@ fn unique(base: &str, used: &mut BTreeSet<String>) -> String {
|
||||
name
|
||||
}
|
||||
|
||||
/// Whether `built`, the bodies a feature made, are the ones its `stored` names give, one for one. A
|
||||
/// name is built as stored, or as `stored (n)` when a body of that name exists already: the solids
|
||||
/// of a STEP file that share a label are stored as `X`, `X`, `X` and built as `X`, `X (2)`, `X (3)`,
|
||||
/// and a second import of a file builds `Aa (2)` for the stored `Aa`.
|
||||
fn named_by(stored: &[String], built: &[String]) -> bool {
|
||||
!stored.is_empty()
|
||||
&& stored.len() == built.len()
|
||||
&& stored.iter().zip(built).all(|(stored, built)| {
|
||||
let base = stored.trim();
|
||||
!base.is_empty()
|
||||
&& (built == base
|
||||
|| built
|
||||
.strip_prefix(base)
|
||||
.and_then(|rest| rest.strip_prefix(" (")?.strip_suffix(')'))
|
||||
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())))
|
||||
})
|
||||
}
|
||||
|
||||
fn graph(doc: &Document, root: u64) -> Result<Vec<u64>> {
|
||||
let definitions: BTreeSet<_> = doc.components.iter().map(|c| c.id).collect();
|
||||
let mut children: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
|
||||
@@ -271,17 +289,26 @@ pub(super) fn paste(
|
||||
}
|
||||
// Collect historical outputs too: a later Combine may have consumed a named tool body.
|
||||
let mut outputs: BTreeMap<u64, Vec<String>> = BTreeMap::new();
|
||||
// The names the bodies of a feature have in the model, where they are its stored names, numbered.
|
||||
let mut shown: BTreeMap<u64, Vec<String>> = BTreeMap::new();
|
||||
for f in &feats {
|
||||
if let Some(result) = s.model.result(f.id) {
|
||||
let created: Vec<_> = result.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect();
|
||||
for name in &created {
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
|
||||
}
|
||||
if named_by(&f.body_names, &created) {
|
||||
shown.insert(f.id, created.clone());
|
||||
}
|
||||
outputs.insert(f.id, created);
|
||||
}
|
||||
for name in &f.body_names {
|
||||
if !name.is_empty() {
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
|
||||
// Later features name a body as the model shows it. Where that differs from the stored name,
|
||||
// the stored name is only a label (the first body of another import may be called so).
|
||||
if !shown.contains_key(&f.id) {
|
||||
for name in &f.body_names {
|
||||
if !name.is_empty() {
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
|
||||
}
|
||||
}
|
||||
}
|
||||
if bodies.len() > MAX_WORK {
|
||||
@@ -379,7 +406,11 @@ pub(super) fn paste(
|
||||
f.id = *features.get(&old.id).ok_or_else(|| bad(CMD, "missing feature mapping"))?;
|
||||
f.name = names.get(&old.name).cloned().ok_or_else(|| bad(CMD, "missing feature name"))?;
|
||||
f.component = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing component mapping"))?;
|
||||
if f.body_names.is_empty() {
|
||||
if let Some(names) = shown.get(&old.id) {
|
||||
// Stored as `X`, `X`, `X` and built as `X`, `X (2)`, `X (3)`: the copy is stored under the
|
||||
// names of the bodies, so that it builds the names the copied features after it use.
|
||||
f.body_names = names.clone();
|
||||
} else if f.body_names.is_empty() {
|
||||
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
|
||||
}
|
||||
f.remap_clone(&features, &names, &bodies, ¶meters)?;
|
||||
|
||||
@@ -552,3 +552,169 @@ fn frozen_r6_bore_copy_uses_current_geometry_despite_mesh_measure_bias() {
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s);
|
||||
}
|
||||
|
||||
/// A STEP file of three 10 mm boxes at x = 0, 30 and 60 (`Aa`, `Bb`, `Cc`). With `shared` the three
|
||||
/// solids carry one label (`X`), as the parts of many assembly files do.
|
||||
fn three_box_step(tag: &str, shared: bool) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-paste-new-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::default();
|
||||
for (i, label) in ["Aa", "Bb", "Cc"].iter().enumerate() {
|
||||
let corner = [30.0 * i as f64, 0.0, 0.0];
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":corner,"operation":"new_body","body_name":label}));
|
||||
}
|
||||
let path = dir.join("Three.step");
|
||||
run(&mut s, "file.export", json!({"path":path.to_string_lossy()}));
|
||||
if shared {
|
||||
let mut text = std::fs::read_to_string(&path).unwrap();
|
||||
for label in ["Aa", "Bb", "Cc"] {
|
||||
assert!(text.contains(&format!("MANIFOLD_SOLID_BREP('{label}'")), "the file names its solids: {label}");
|
||||
text = text.replace(&format!("MANIFOLD_SOLID_BREP('{label}'"), "MANIFOLD_SOLID_BREP('X'");
|
||||
}
|
||||
std::fs::write(&path, text).unwrap();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn body_names(s: &Session) -> Vec<String> {
|
||||
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Where the bodies of component `part` are: the lowest corner of each, rounded, in model order.
|
||||
fn corners(s: &Session, part: u64) -> Vec<Vec<f64>> {
|
||||
let state = s.model.state();
|
||||
state
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| s.doc.model_body_component(b) == part)
|
||||
.map(|b| {
|
||||
let min = b.mesh().bounds().min;
|
||||
vec![min.x.round(), min.y.round(), min.z.round()]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Lifts the first of the three bodies by 20 and the second by 50, as two features of the active
|
||||
/// component.
|
||||
fn lift_two(s: &mut Session, bodies: [&str; 3]) {
|
||||
run(s, "solid.move", json!({"bodies":[bodies[0]],"translate":[0,0,20],"name":"Lift first"}));
|
||||
run(s, "solid.move", json!({"bodies":[bodies[1]],"translate":[0,0,50],"name":"Lift second"}));
|
||||
}
|
||||
|
||||
const LIFTED: [[f64; 3]; 3] = [[0.0, 0.0, 20.0], [30.0, 0.0, 50.0], [60.0, 0.0, 0.0]];
|
||||
|
||||
fn lifted() -> Vec<Vec<f64>> {
|
||||
LIFTED.iter().map(|c| c.to_vec()).collect()
|
||||
}
|
||||
|
||||
/// A component whose Import feature holds the three solids of `path`. `file.insert_step` puts its
|
||||
/// feature into the root even while a component is active, so the feature is moved into it afterwards.
|
||||
fn component_with_import(s: &mut Session, path: &std::path::Path) -> u64 {
|
||||
let part = component(s, "Part");
|
||||
let import = run(s, "file.insert_step", json!({"path":path.to_string_lossy()}))["feature"].as_u64().unwrap();
|
||||
s.doc_mut().feature_mut(import).unwrap().component = part;
|
||||
s.refresh();
|
||||
part
|
||||
}
|
||||
|
||||
/// A control: solids with labels of their own are built as they are labelled.
|
||||
#[test]
|
||||
fn paste_new_of_a_component_with_an_import_moves_the_copy_of_each_body() {
|
||||
let path = three_box_step("distinct", false);
|
||||
let mut s = Session::default();
|
||||
let part = component_with_import(&mut s, &path);
|
||||
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc"]);
|
||||
lift_two(&mut s, ["Aa", "Bb", "Cc"]);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":part}))["component"].as_u64().unwrap();
|
||||
assert_eq!(corners(&s, part), lifted());
|
||||
assert_eq!(corners(&s, copied), lifted());
|
||||
}
|
||||
|
||||
/// Solids of a STEP file that share one label are stored as `X`, `X`, `X` and built as `X`,
|
||||
/// `X (2)`, `X (3)`, and the features made on them name them so. The copy of the component has
|
||||
/// those features act on its own bodies, each on its own.
|
||||
#[test]
|
||||
fn paste_new_of_an_import_with_one_label_on_all_solids_moves_the_copy_of_each_body() {
|
||||
let path = three_box_step("shared", true);
|
||||
let mut s = Session::default();
|
||||
let part = component_with_import(&mut s, &path);
|
||||
assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]);
|
||||
lift_two(&mut s, ["X", "X (2)", "X (3)"]);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":part}))["component"].as_u64().unwrap();
|
||||
assert_eq!(corners(&s, part), lifted());
|
||||
assert_eq!(corners(&s, copied), lifted(), "{:?}", body_names(&s));
|
||||
// The copy keeps its bodies and their places when the design is saved and opened again.
|
||||
let names = body_names(&s);
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(body_names(&reopened), names);
|
||||
assert_eq!(corners(&reopened, copied), lifted());
|
||||
}
|
||||
|
||||
/// No shared label is needed: a design linked a second time builds `Aa (2)`, `Bb (2)`, `Cc (2)`
|
||||
/// from the names `Aa`, `Bb`, `Cc`, and the features made on those bodies name them so.
|
||||
#[test]
|
||||
fn paste_new_of_a_design_linked_twice_moves_the_copy_of_each_body() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-paste-new-linked-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let design = dir.join("Three.solidcraft");
|
||||
let mut part = Session::default();
|
||||
for (i, label) in ["Aa", "Bb", "Cc"].iter().enumerate() {
|
||||
let corner = [30.0 * i as f64, 0.0, 0.0];
|
||||
run(&mut part, "solid.box", json!({"length":10,"width":10,"height":10,"corner":corner,"operation":"new_body","body_name":label}));
|
||||
}
|
||||
run(&mut part, "file.save_as", json!({"path":design.to_string_lossy()}));
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.insert_design", json!({"path":design.to_string_lossy()}));
|
||||
let second = run(&mut s, "component.insert_design", json!({"path":design.to_string_lossy()}));
|
||||
let linked = second["component"].as_u64().unwrap();
|
||||
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
|
||||
run(&mut s, "material.assign", json!({"bodies":["Aa"],"material":"Steel"}));
|
||||
run(&mut s, "material.assign", json!({"bodies":["Aa (2)"],"material":"Brass"}));
|
||||
run(&mut s, "component.activate", json!({"component":linked}));
|
||||
lift_two(&mut s, ["Aa (2)", "Bb (2)", "Cc (2)"]);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":linked}))["component"].as_u64().unwrap();
|
||||
assert_eq!(corners(&s, linked), lifted());
|
||||
assert_eq!(corners(&s, copied), lifted(), "{:?}", body_names(&s));
|
||||
// The material of a body goes to its copy, and no material is kept for a body that does not exist.
|
||||
let names = body_names(&s);
|
||||
assert!(s.doc.materials.contains_key("Aa (2) (copy)"));
|
||||
assert_eq!(s.doc.materials.get("Aa (2) (copy)"), s.doc.materials.get("Aa (2)"));
|
||||
assert!(s.doc.materials.keys().all(|body| names.contains(body)), "{:?} for {names:?}", s.doc.materials.keys().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// An OBJ file of three 10 mm cubes at x = 0, 30 and 60, every object called `X`.
|
||||
fn three_cube_obj(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-paste-new-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let corners = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
|
||||
let faces = [[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [3, 7, 6], [3, 6, 2], [0, 4, 7], [0, 7, 3], [1, 2, 6], [1, 6, 5]];
|
||||
let mut text = String::new();
|
||||
for i in 0..3 {
|
||||
text += "o X\n";
|
||||
for c in corners {
|
||||
text += &format!("v {} {} {}\n", 30 * i + 10 * c[0], 10 * c[1], 10 * c[2]);
|
||||
}
|
||||
for f in faces {
|
||||
text += &format!("f {} {} {}\n", 8 * i + f[0] + 1, 8 * i + f[1] + 1, 8 * i + f[2] + 1);
|
||||
}
|
||||
}
|
||||
let path = dir.join("Three.obj");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// The same for the objects of an OBJ file (3MF and STL files give the same kind of import).
|
||||
#[test]
|
||||
fn paste_new_of_a_mesh_import_with_one_label_on_all_objects_moves_the_copy_of_each_body() {
|
||||
let path = three_cube_obj("shared-obj");
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "Part");
|
||||
let import = run(&mut s, "file.insert_mesh", json!({"path":path.to_string_lossy()}))["feature"].as_u64().unwrap();
|
||||
s.doc_mut().feature_mut(import).unwrap().component = part;
|
||||
s.refresh();
|
||||
assert_eq!(body_names(&s), ["X", "X (2)", "X (3)"]);
|
||||
lift_two(&mut s, ["X", "X (2)", "X (3)"]);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":part}))["component"].as_u64().unwrap();
|
||||
assert_eq!(corners(&s, part), lifted());
|
||||
assert_eq!(corners(&s, copied), lifted(), "{:?}", body_names(&s));
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -844,6 +853,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
spec.id,
|
||||
"sheet.flange"
|
||||
| "sheet.hem"
|
||||
| "sheet.perforate"
|
||||
| "sheet.fold"
|
||||
| "sheet.unfold"
|
||||
| "sheet.refold"
|
||||
@@ -859,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()
|
||||
@@ -112,8 +128,9 @@ fn xy(v: Vec2) -> Value {
|
||||
}
|
||||
|
||||
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
|
||||
/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active.
|
||||
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2)> {
|
||||
/// the sketch id, a point inside the face (sketch coordinates), and the picked body's name.
|
||||
/// The sketch is left active.
|
||||
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2, String)> {
|
||||
let none = || match face_body {
|
||||
Some(b) => bad(cmd, format!("no face of `{b}` at `face`")),
|
||||
None => bad(cmd, "no body face at `face`"),
|
||||
@@ -183,7 +200,7 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body:
|
||||
// The face's edges came in as linked projections: they follow the face when earlier
|
||||
// features change, so nothing is copied.
|
||||
if r.get("projected").is_some_and(|x| !x.is_null()) {
|
||||
return Ok((sid, plane.to_local(author_inside)));
|
||||
return Ok((sid, plane.to_local(author_inside), body));
|
||||
}
|
||||
for e in &edges {
|
||||
let pts: Vec<Vec2> = e.iter().map(|p| plane.to_local(*p)).collect();
|
||||
@@ -221,9 +238,13 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body:
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok((sid, plane.to_local(author_inside)))
|
||||
Ok((sid, plane.to_local(author_inside), body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "face_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "face_join_tests.rs"]
|
||||
mod join_tests;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! #226: a face's outward extrusion must not join an assembly neighbour it only touches.
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{Document, FeatureKind};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||
}
|
||||
|
||||
fn assembly(cap_first: bool) -> Session {
|
||||
let mut s = Session::default();
|
||||
let wall = json!({"length":40,"width":30,"height":20,"body_name":"Wall"});
|
||||
let cap = json!({"corner":[0,0,20],"length":40,"width":30,"height":5,"body_name":"Cap"});
|
||||
for params in if cap_first { [cap, wall] } else { [wall, cap] } {
|
||||
run(&mut s, "solid.box", params);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn check_wall(s: &Session, count: usize) {
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none() && result.warning.is_none()), "{:?}", s.model.results);
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), count);
|
||||
let wall = state.body("Wall").unwrap();
|
||||
let measure = solidcraft_kernel::measure(&wall.body).unwrap();
|
||||
assert!((measure.volume - 30_000.0).abs() < 1e-6, "{measure:?}");
|
||||
assert!(measure.bbox.max.dist(Vec3::new(50.0, 30.0, 20.0)) < 1e-9);
|
||||
assert!(wall.body.contains(Vec3::new(49.0, 15.0, 10.0)));
|
||||
let cap = solidcraft_kernel::measure(&state.body("Cap").unwrap().body).unwrap();
|
||||
assert!((cap.volume - 6000.0).abs() < 1e-6);
|
||||
assert!(cap.bbox.max.dist(Vec3::new(40.0, 30.0, 25.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_join_and_press_pull_leave_the_edge_touching_cap_in_either_body_order() {
|
||||
for cap_first in [true, false] {
|
||||
for operation in ["join", "auto", "press_pull"] {
|
||||
let mut s = assembly(cap_first);
|
||||
let before = s.model.state();
|
||||
let params = json!({"face":[40,15,10],"distance":10,"operation":operation});
|
||||
let command = if operation == "press_pull" { "solid.press_pull" } else { "solid.extrude" };
|
||||
let result = run(&mut s, command, params);
|
||||
assert_eq!(result["changed_bodies"], json!(["Wall"]));
|
||||
assert_eq!(result["removed_bodies"], json!([]));
|
||||
assert!(s.model.state().body("Cap").unwrap().body.same(&before.body("Cap").unwrap().body));
|
||||
check_wall(&s, 2);
|
||||
let saved = s.doc.to_json();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.model.state().body("Wall").unwrap().body.same(&before.body("Wall").unwrap().body));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
check_wall(&Session::new(Document::from_json(&saved).unwrap()), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_join_respects_explicit_targets_and_participants() {
|
||||
for key in ["targets", "participants"] {
|
||||
for all in [false, true] {
|
||||
let mut s = assembly(!all);
|
||||
let mut params = json!({"face":[40,15,10],"distance":10,"operation":"join"});
|
||||
// Joining the wall first permits the cap's full base face to join afterwards.
|
||||
params[key] = if all { json!(["*"]) } else { json!(["Wall", "Cap"]) };
|
||||
run(&mut s, "solid.extrude", params);
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), 1, "{key}");
|
||||
assert!((solidcraft_kernel::measure(&state.body("Wall").unwrap().body).unwrap().volume - 36_000.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_join_maps_the_picked_instance_body_and_point_together() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Repeated"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Wall"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let occurrence = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[100,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":occurrence}));
|
||||
let world = s.world_state();
|
||||
let body = world.bodies.iter().find(|body| body.body.contains(Vec3::new(139.0, 15.0, 10.0))).unwrap().name.clone();
|
||||
assert_ne!(body, "Wall", "exercise an instance body name");
|
||||
let result = run(&mut s, "solid.extrude", json!({"face":[140,15,10],"face_body":body,"distance":10,"operation":"join"}));
|
||||
let FeatureKind::Extrude { targets, .. } = &s.doc.feature(result["feature"].as_u64().unwrap()).unwrap().kind else { panic!("extrude") };
|
||||
assert_eq!(targets, &["Wall"]);
|
||||
let world = s.world_state();
|
||||
assert_eq!(world.bodies.len(), 2);
|
||||
for point in [Vec3::new(49.0, 15.0, 10.0), Vec3::new(149.0, 15.0, 10.0)] {
|
||||
assert!(world.bodies.iter().any(|body| body.body.contains(point)), "{point:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_join_of_a_step_import_keeps_all_32_neighbours_unchanged() {
|
||||
let mut source = assembly(true);
|
||||
for i in 0..31 {
|
||||
run(&mut source, "solid.box", json!({"corner":[100+20*i,100,0],"length":5,"width":5,"height":5,"body_name":format!("Distant{i}")}));
|
||||
}
|
||||
let step = solidcraft_io::export(&source.model.state(), &[], solidcraft_io::Format::Step, "Original assembly").unwrap();
|
||||
let imported = solidcraft_io::step_import_feature(&step, "original-assembly.step").unwrap();
|
||||
let mut doc = Document::new("Imported assembly");
|
||||
let feature = doc.add_feature(imported.kind, None).unwrap();
|
||||
doc.feature_mut(feature).unwrap().body_names = imported.body_names;
|
||||
let mut s = Session::new(doc);
|
||||
assert_eq!(s.doc.features.len(), 1);
|
||||
let before = s.model.state();
|
||||
let result = run(&mut s, "solid.extrude", json!({"face":[40,15,10],"distance":"10 mm","direction":"positive","operation":"join"}));
|
||||
assert_eq!(result["changed_bodies"], json!(["Wall"]));
|
||||
check_wall(&s, 33);
|
||||
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check_wall(&reopened, 33);
|
||||
for body in before.bodies.iter().filter(|body| body.name != "Wall") {
|
||||
let after = s.model.state();
|
||||
assert!(body.body.same(&after.body(&body.name).unwrap().body), "{}", body.name);
|
||||
let cold = reopened.model.state();
|
||||
let original = solidcraft_kernel::measure(&body.body).unwrap();
|
||||
let restored = solidcraft_kernel::measure(&cold.body(&body.name).unwrap().body).unwrap();
|
||||
assert!((original.volume - restored.volume).abs() < 1e-9, "{}", body.name);
|
||||
assert!(original.bbox.min.dist(restored.bbox.min) < 1e-9 && original.bbox.max.dist(restored.bbox.max) < 1e-9);
|
||||
}
|
||||
let FeatureKind::Extrude { targets, .. } = &s.doc.feature(result["feature"].as_u64().unwrap()).unwrap().kind else { panic!("extrude") };
|
||||
assert_eq!(targets, &["Wall"]);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, expr::Kind, point_expr};
|
||||
use solidcraft_doc::{
|
||||
AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, SweepOrientation, ThinSide, ThinWall, expr::Kind, point_expr,
|
||||
};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{CommandSpec, not_in_sketch};
|
||||
@@ -13,7 +15,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?"),
|
||||
.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] (Join from a face defaults to that face's body; explicit targets or participants override); 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")
|
||||
@@ -21,7 +23,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?, targets?, name?, body_name?"),
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
|
||||
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"),
|
||||
@@ -67,11 +69,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 +81,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 +98,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")
|
||||
@@ -119,6 +121,11 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
|
||||
let sid = feature_sketch(s, p, "Extrude").ok()?;
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(sid)?;
|
||||
if bool_(p, "thin").unwrap_or(false) {
|
||||
let thin = thin_param(s, p, sid, "solid.extrude").ok()?;
|
||||
let q = thin_regions(s, ss, &thin).ok()?.first()?.interior_point();
|
||||
return Some((ss.plane.to_world(q), ss.plane.normal()));
|
||||
}
|
||||
let ps = &ss.profiles;
|
||||
let q = match profiles(p, "Extrude").ok()? {
|
||||
ProfileSel::All => ps.first()?.region.interior_point(),
|
||||
@@ -132,9 +139,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);
|
||||
@@ -156,10 +170,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
|
||||
@@ -497,30 +514,103 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.extrude";
|
||||
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
|
||||
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
|
||||
let (sketch, face_profile, face_body) = match p.get("face").and_then(vec3) {
|
||||
Some(fp) => {
|
||||
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }))
|
||||
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body))
|
||||
}
|
||||
None => (feature_sketch(s, p, cmd)?, None),
|
||||
None => (feature_sketch(s, p, cmd)?, None, None),
|
||||
};
|
||||
let extent = extent_param(s, p, cmd)?;
|
||||
let thin = match bool_(p, "thin") {
|
||||
Some(true) => {
|
||||
if face_profile.is_some() || p.get("profiles").is_some() {
|
||||
return Err(bad(cmd, "a thin extrude follows `curves`; it takes no `profiles` or `face`"));
|
||||
}
|
||||
Some(thin_param(s, p, sketch, cmd)?)
|
||||
}
|
||||
_ if p.get("thin").is_some_and(|v| !v.is_boolean()) => return Err(bad(cmd, "`thin` must be true or false")),
|
||||
_ => None,
|
||||
};
|
||||
let operation = match str_(p, "operation") {
|
||||
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||
_ => operation(p, cmd)?,
|
||||
};
|
||||
let mut targets = string_list(p, "targets");
|
||||
if operation == Operation::Join && targets.is_empty() && string_list(p, "participants").is_empty() {
|
||||
// A face's outward extension joins that body. Assembly neighbours can touch only an
|
||||
// edge of the tool: trying to join them first fails, or absorbs unrelated parts (#226).
|
||||
// Store the resolved target so recomputes and reopening keep the same scope.
|
||||
targets.extend(face_body);
|
||||
}
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: match face_profile {
|
||||
Some(f) => f,
|
||||
None if thin.is_some() => ProfileSel::All,
|
||||
None => profiles(p, cmd)?,
|
||||
},
|
||||
extent,
|
||||
operation: match str_(p, "operation") {
|
||||
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||
_ => operation(p, cmd)?,
|
||||
},
|
||||
targets: string_list(p, "targets"),
|
||||
operation,
|
||||
targets,
|
||||
thin,
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
/// A thin extrude's wall from its parameters: the sketch's curves it follows (every drawn
|
||||
/// curve when none are given), its thickness and which side of them it lies on.
|
||||
fn thin_param(s: &Session, p: &Value, sketch: u64, cmd: &str) -> Result<ThinWall> {
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
|
||||
let mut curves = string_list(p, "curves");
|
||||
if curves.is_empty() {
|
||||
curves = ss.sketch.curves.iter().filter(|c| !c.construction && !c.centerline && c.link.is_none()).map(|c| c.id.clone()).collect();
|
||||
}
|
||||
if curves.is_empty() || curves.len() > 1000 {
|
||||
return Err(bad(cmd, "a thin extrude needs 1…1000 sketch curves (`curves`)"));
|
||||
}
|
||||
if let Some(c) = curves.iter().find(|c| ss.sketch.curve_index(c).is_none()) {
|
||||
return Err(bad(cmd, format!("no curve `{c}` in the sketch")));
|
||||
}
|
||||
let thickness = req_expr(cmd, p, "thickness")?;
|
||||
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
|
||||
let side = match str_(p, "side") {
|
||||
None => ThinSide::Center,
|
||||
Some(o) => ThinSide::parse(o).ok_or_else(|| bad(cmd, format!("unknown side `{o}` (center, inside, outside or two_sides)")))?,
|
||||
};
|
||||
let thickness2 = expr(p, "thickness2");
|
||||
match (&thickness2, side) {
|
||||
(Some(t), ThinSide::TwoSides) => check_expr(s, t, Kind::Length, cmd, "thickness2")?,
|
||||
(None, ThinSide::TwoSides) => return Err(bad(cmd, "`side: two_sides` needs `thickness2` (the thickness outside)")),
|
||||
(Some(_), _) => return Err(bad(cmd, "`thickness2` goes with `side: two_sides`")),
|
||||
(None, _) => {}
|
||||
}
|
||||
let thin = ThinWall { curves, thickness, side, thickness2 };
|
||||
// Refuse a wall that can't be built now, with the reason, rather than a failed feature.
|
||||
thin_regions(s, ss, &thin).map_err(|e| bad(cmd, e))?;
|
||||
Ok(thin)
|
||||
}
|
||||
|
||||
/// The regions of a thin wall in its (solved) sketch, with the current parameter values.
|
||||
fn thin_regions(s: &Session, ss: &solidcraft_doc::SolvedSketch, thin: &ThinWall) -> std::result::Result<Vec<solidcraft_geom::Region2>, String> {
|
||||
let length = |e: &str| s.doc.eval(&s.doc.with_design_unit(e, Kind::Length), Kind::Length).map_err(|e| e.to_string());
|
||||
let t = length(&thin.thickness)?;
|
||||
let (inside, outside) = match thin.side {
|
||||
ThinSide::Center => (t / 2.0, t / 2.0),
|
||||
ThinSide::Inside => (t, 0.0),
|
||||
ThinSide::Outside => (0.0, t),
|
||||
ThinSide::TwoSides => (t, length(thin.thickness2.as_deref().unwrap_or("0"))?),
|
||||
};
|
||||
let curves: Vec<Vec<solidcraft_geom::Seg2>> = thin
|
||||
.curves
|
||||
.iter()
|
||||
.filter_map(|id| ss.sketch.curve_index(id))
|
||||
.map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
|
||||
.collect();
|
||||
solidcraft_sketch::thin_wall_regions(&curves, inside, outside).map_err(|e| format!("thin wall: {e}"))
|
||||
}
|
||||
|
||||
/// An extrude's extent from its parameters: distance (or through all, or to an object), the
|
||||
/// side(s), a second distance, a start offset, a taper, and from an object.
|
||||
pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent> {
|
||||
@@ -552,8 +642,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,
|
||||
@@ -570,7 +667,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A planar body face revolves like a profile (its edges projected into a sketch on it).
|
||||
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
|
||||
Some(fp) => {
|
||||
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
let (id, inside, _) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
s.active_sketch = None;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }))
|
||||
}
|
||||
@@ -1253,7 +1350,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()) {
|
||||
@@ -1472,17 +1569,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();
|
||||
@@ -1504,7 +1601,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)?;
|
||||
@@ -1534,6 +1631,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if path.is_empty() {
|
||||
return Err(bad(cmd, "`path` must list curve ids"));
|
||||
}
|
||||
let (orientation, twist, scale) = sweep_options(s, p, cmd)?;
|
||||
add_feature(
|
||||
s,
|
||||
p,
|
||||
@@ -1542,12 +1640,58 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
profiles: profiles(p, cmd)?,
|
||||
path_sketch,
|
||||
path,
|
||||
orientation,
|
||||
twist,
|
||||
scale,
|
||||
operation: operation(p, cmd)?,
|
||||
targets: string_list(p, "targets"),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// A sweep's `orientation`, `twist` and `scale` (solid.sweep and surface.sweep), checked: an
|
||||
/// orientation the sweep knows, a twist within four turns, an end scale within 0.01…100.
|
||||
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<String>, Option<String>)> {
|
||||
let orientation = match p.get("orientation") {
|
||||
None | Some(Value::Null) => SweepOrientation::Perpendicular,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
|
||||
Some(v) => {
|
||||
let d = vec3(v)
|
||||
.or_else(|| v.get("direction").and_then(vec3))
|
||||
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
|
||||
if !(d.len() > 1e-9) {
|
||||
return Err(bad(cmd, "the `orientation` direction must not be zero"));
|
||||
}
|
||||
SweepOrientation::Direction(d)
|
||||
}
|
||||
};
|
||||
let twist = match p.get("twist") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
let t = expr(p, "twist").ok_or_else(|| bad(cmd, "`twist` must be an angle or an expression"))?;
|
||||
let v = s.doc.eval(&t, Kind::Angle).map_err(|e| bad(cmd, format!("twist: {e}")))?;
|
||||
if !(v.is_finite() && v.abs() <= solidcraft_kernel::SweepControl::MAX_TWIST) {
|
||||
return Err(bad(cmd, "`twist` must be within four turns (1440 deg)"));
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
};
|
||||
let scale = match p.get("scale") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
let k = expr(p, "scale").ok_or_else(|| bad(cmd, "`scale` must be a number or an expression"))?;
|
||||
let v = s.doc.eval(&k, Kind::Unitless).map_err(|e| bad(cmd, format!("scale: {e}")))?;
|
||||
let range = solidcraft_kernel::SweepControl::MIN_SCALE..=solidcraft_kernel::SweepControl::MAX_SCALE;
|
||||
if !(v.is_finite() && range.contains(&v)) {
|
||||
return Err(bad(cmd, "`scale` (the end scale) must be between 0.01 and 100"));
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
};
|
||||
Ok((orientation, twist, scale))
|
||||
}
|
||||
|
||||
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.loft";
|
||||
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
|
||||
@@ -1645,14 +1789,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) })
|
||||
@@ -1666,6 +1810,10 @@ mod patterns_tests;
|
||||
#[path = "revolve_convention_tests.rs"]
|
||||
mod revolve_convention_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sweep_options_tests.rs"]
|
||||
mod sweep_options_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_topology_tests.rs"]
|
||||
mod offset_topology_tests;
|
||||
@@ -1696,3 +1844,11 @@ mod solid_move_copy_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "move_in_sketch_tests.rs"]
|
||||
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"));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user