mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Compare commits
211 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22d1cd7032 | |||
| 6a0dc18655 | |||
| e006dbf1af | |||
| 8241a750fa | |||
| 843c548589 | |||
| 7934edcad4 | |||
| b56648c055 | |||
| 6b30326e6d | |||
| 39f6d68a50 | |||
| 5fbbc13885 | |||
| b6806b16cf | |||
| e20cf1858a | |||
| 6ad391fd75 | |||
| 2d069ca186 | |||
| 02fe495d38 | |||
| 1e984c8e46 | |||
| a47e82ba94 | |||
| be985f136d | |||
| e8e2ccc8a9 | |||
| 4e10c31fdd | |||
| 3047aa60cd | |||
| f0c5bfcdf2 | |||
| d8a088f46a | |||
| 17b48d9e7e | |||
| 56747edb46 | |||
| aa31e56477 | |||
| ef53bc46f3 | |||
| 8b7fa2602e | |||
| fd422a3fc1 | |||
| e382a77350 | |||
| a18bedddf6 | |||
| 29e72806f6 | |||
| fb55c1341a | |||
| d4ab9545a1 | |||
| c2ba3657f3 | |||
| d0a4443939 | |||
| 2bfec256b7 | |||
| e8c340c59d | |||
| 20a7dd8764 | |||
| 3fa8fbd162 | |||
| 27529d9742 | |||
| d43c414b7b | |||
| 8b97b9ed62 | |||
| 4c7ea34a68 | |||
| b156b5e09c | |||
| 4823c293dd | |||
| 762b6fe934 | |||
| 073acd295b | |||
| 1744fbb262 | |||
| 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 | |||
| 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
|
||||
@@ -306,11 +343,41 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-fxc
|
||||
- run: cargo test -p solidcraft-gpu --profile ci --locked
|
||||
# One test at a time: WARP's shader JIT crashes now and then (a null read in
|
||||
# d3d10warp.dll's JIT optimizer, about 1 run in 60) when several devices compile compute
|
||||
# pipelines at once, which the parallel tests do. Minidumps showed it in WARP itself, not
|
||||
# in SolidCraft or FXC; serial runs never hit it.
|
||||
- run: cargo test -p solidcraft-gpu --profile ci --locked -- --test-threads=1
|
||||
|
||||
# The Windows app starts D3D12 first and only the backend it uses (a Vulkan driver crashed the
|
||||
# app at startup on Intel). Start the real app on the runner (its D3D12 adapter is WARP) with
|
||||
# several saved settings and safe mode, and check each draws a frame and quits cleanly.
|
||||
windows-startup:
|
||||
name: Windows app startup
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-startup
|
||||
- name: Backend order and probing on Windows
|
||||
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests native_frame::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
|
||||
- name: Resize border from the first frame
|
||||
shell: pwsh
|
||||
run: tools/windows-resize-smoke.ps1 -Exe target/ci/solidcraft.exe
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc]
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -325,6 +392,7 @@ jobs:
|
||||
DOCS: ${{ needs.doc-tests.result }}
|
||||
ORACLE: ${{ needs.oracle.result }}
|
||||
WINDOWS_FXC: ${{ needs.windows-fxc.result }}
|
||||
WINDOWS_STARTUP: ${{ needs.windows-startup.result }}
|
||||
run: |
|
||||
test "$CHANGES" = success
|
||||
test "$CHECKS" = success
|
||||
@@ -335,6 +403,7 @@ jobs:
|
||||
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,13 @@ 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 three 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), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers) and the Windows-only
|
||||
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
|
||||
test of the window without OS decorations).
|
||||
- **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
|
||||
@@ -75,6 +81,10 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.start(id)`. Colours come from `theme::Tokens`.
|
||||
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
|
||||
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
|
||||
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
|
||||
camera input (a test rejects it).
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
|
||||
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
|
||||
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
|
||||
|
||||
+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,13 @@ 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 three 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), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers) and the Windows-only
|
||||
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
|
||||
test of the window without OS decorations).
|
||||
- **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
|
||||
|
||||
+9
-1
@@ -107,13 +107,21 @@ 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.
|
||||
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
|
||||
`crates/engine/src/cmd/`, engine tests that measure the result (volume, area,
|
||||
face/edge/vertex counts against a value you can derive), and for UI work a scenario (below).
|
||||
6. **One set of camera controls.** Every page or canvas that shows a view (the modelling
|
||||
viewport, the Render preview, and any new page such as a flat pattern or drawing) calls
|
||||
`navigation::handle` (`crates/ui-egui/src/navigation.rs`) for its camera input, with
|
||||
`NavCaps` saying whether it can orbit (off for flat 2D pages) and pick. New canvases must use
|
||||
`navigation::handle`; never read drags, the wheel or pinch to move the camera yourself. The
|
||||
`hand_rolled_camera_input_is_rejected` test fails if a page does.
|
||||
|
||||
[AGENTS.md](AGENTS.md) has the long form of these rules. It's written for AI agents, but it's
|
||||
the most complete description of the house style.
|
||||
|
||||
Generated
+674
-37
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.1"
|
||||
version = "0.13.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -24,13 +24,20 @@ 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"
|
||||
moxcms = { version = "0.8.1", default-features = false, features = ["extended_range"] }
|
||||
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 +59,7 @@ ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
||||
|
||||
# images
|
||||
image = { version = "0.25", default-features = false, features = ["png", "hdr"] }
|
||||
png = "0.18.1"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
|
||||
@@ -21,6 +21,8 @@ model through the built-in MCP server.
|
||||
- **Windows:** download and run the [installer][windows-msi].
|
||||
Also for [ARM64][windows-arm64-msi] and [32-bit][windows-x86-msi] PCs, or as a
|
||||
[portable zip][windows-portable] that runs without installing.
|
||||
With [WinGet](https://learn.microsoft.com/windows/package-manager/winget/):
|
||||
`winget install bherbruck.SolidCraft`.
|
||||
- **macOS:** open the [disk image][macos-dmg] and drag SolidCraft into Applications
|
||||
(Apple silicon and Intel).
|
||||
- **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it.
|
||||
@@ -128,12 +130,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 -->
|
||||
|
||||
|
||||
+112
-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,27 @@ 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.
|
||||
|
||||
## Interactive modelling
|
||||
|
||||
- New Body extrusion and face-offset previews show completed intermediate geometry while a
|
||||
drag continues, then catch up to the exact final value. Changes to the selected geometry,
|
||||
operation or feature still discard obsolete results. Distance arrows and their value boxes
|
||||
follow the current frame's input.
|
||||
|
||||
## Physical material data
|
||||
|
||||
- Physical material card validation (#923/#922) provides typed SI mechanical and
|
||||
@@ -67,8 +81,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 +103,57 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Render foundation
|
||||
|
||||
- Explicit texture normalization (#1218) produces completed float RGBA with
|
||||
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
|
||||
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
|
||||
gamut clipping only when explicitly requested. It is not a renderer/resource
|
||||
consumer; see [the supported policy and limits](docs/render-texture-normalization.md).
|
||||
|
||||
- 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 +222,20 @@ 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).
|
||||
- Agent chat (off by default, desktop): a side panel that drives a coding agent the user
|
||||
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, …) over the
|
||||
Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works
|
||||
through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or
|
||||
Deny, crash reports are off limits, 24 changes per message, one undo step per message, and
|
||||
the agent's whole process group ends with the panel or the app. Docks left or right,
|
||||
minimises to a strip. See [agent chat](docs/agent-chat.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
|
||||
|
||||
@@ -225,6 +306,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
|
||||
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
|
||||
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
|
||||
curved chamfer chains with straight planar ends can terminate across two existing planar
|
||||
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
|
||||
whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
|
||||
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
|
||||
@@ -242,7 +325,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 +360,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 +508,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 +562,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.
|
||||
|
||||
+2269
-270
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");
|
||||
}
|
||||
}
|
||||
@@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "Response",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList",
|
||||
] }
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/OFL.txt" data-target-path="fonts" />
|
||||
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
|
||||
<!-- Installable as an app (manifest.webmanifest); the icons are the Linux exports of the app mark. -->
|
||||
<meta name="theme-color" content="#343840">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/32x32/ai.storyteller.solidcraft.png">
|
||||
<link rel="apple-touch-icon" href="icons/256x256/ai.storyteller.solidcraft.png">
|
||||
<link data-trunk rel="copy-file" href="manifest.webmanifest" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png" data-target-path="icons/32x32" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" data-target-path="icons/256x256" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png" data-target-path="icons/512x512" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="solidcraft_canvas"></canvas>
|
||||
@@ -32,6 +41,10 @@
|
||||
if ("serviceWorker" in navigator) {
|
||||
addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {}));
|
||||
}
|
||||
// Install as an app: the browser's offer can come before the app runs, so it is kept here for
|
||||
// the app's File ▸ Install app… item (src/install.rs). Installed, the browser makes no more offers.
|
||||
addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); window.solidcraftInstall = e; });
|
||||
addEventListener("appinstalled", () => { window.solidcraftInstall = null; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "SolidCraft",
|
||||
"short_name": "SolidCraft",
|
||||
"description": "Open-source parametric CAD built in Rust.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#e8ebf0",
|
||||
"theme_color": "#343840",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"icons": [
|
||||
{ "src": "icons/32x32/ai.storyteller.solidcraft.png", "sizes": "32x32", "type": "image/png" },
|
||||
{ "src": "icons/256x256/ai.storyteller.solidcraft.png", "sizes": "256x256", "type": "image/png" },
|
||||
{ "src": "icons/512x512/ai.storyteller.solidcraft.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Install as an app (`solidcraft_ui_egui::install`). The browser offers it with a
|
||||
//! `beforeinstallprompt` event, which can come before this code runs: index.html keeps the event
|
||||
//! as `window.solidcraftInstall` (and drops it once the app is installed). The offer is good for
|
||||
//! one prompt; a browser that is still willing makes a new one later. Safari makes no offer: it
|
||||
//! gets its steps, unless the app is already running installed.
|
||||
|
||||
use solidcraft_ui_egui::install::{InstallApp, Offer, safari_steps};
|
||||
use wasm_bindgen::{JsCast as _, JsValue};
|
||||
|
||||
/// Where index.html keeps the browser's offer.
|
||||
const OFFER: &str = "solidcraftInstall";
|
||||
|
||||
/// The browser's offer, kept by index.html (None: none, or it was used).
|
||||
fn offer() -> Option<js_sys::Object> {
|
||||
let window = web_sys::window()?;
|
||||
js_sys::Reflect::get(&window, &OFFER.into()).ok()?.dyn_into().ok()
|
||||
}
|
||||
|
||||
/// Show the browser's install prompt (from the File menu click, so the page may).
|
||||
fn prompt() {
|
||||
let Some(offer) = offer() else { return };
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = js_sys::Reflect::set(&window, &OFFER.into(), &JsValue::NULL);
|
||||
}
|
||||
let shown = js_sys::Reflect::get(&offer, &"prompt".into())
|
||||
.and_then(|f| f.dyn_into::<js_sys::Function>().map_err(JsValue::from))
|
||||
.and_then(|f| f.call0(&offer))
|
||||
.and_then(|p| p.dyn_into::<js_sys::Promise>().map_err(JsValue::from));
|
||||
match shown {
|
||||
// Accepted or dismissed, the browser handles it; a refusal is only logged.
|
||||
Ok(p) => wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = wasm_bindgen_futures::JsFuture::from(p).await {
|
||||
log::warn!("install prompt: {e:?}");
|
||||
}
|
||||
}),
|
||||
Err(e) => log::warn!("install prompt: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The page runs as the installed app (its own window, or from the Home Screen on iOS).
|
||||
fn running_installed(window: &web_sys::Window) -> bool {
|
||||
let standalone = window.match_media("(display-mode: standalone)").ok().flatten().is_some_and(|m| m.matches());
|
||||
let home_screen = js_sys::Reflect::get(&window.navigator(), &"standalone".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
standalone || home_screen
|
||||
}
|
||||
|
||||
/// The host service for File ▸ Install app…; the UI is drawn again whenever the browser
|
||||
/// makes or withdraws its offer (even with nothing else happening).
|
||||
pub fn service(ctx: &egui::Context) -> InstallApp {
|
||||
// Safari's steps, decided once: the browser and the way the page runs don't change.
|
||||
let steps = web_sys::window().filter(|w| !running_installed(w)).and_then(|w| {
|
||||
let nav = w.navigator();
|
||||
let vendor = js_sys::Reflect::get(&nav, &"vendor".into()).ok().and_then(|v| v.as_string()).unwrap_or_default();
|
||||
safari_steps(&nav.user_agent().unwrap_or_default(), &vendor, nav.max_touch_points())
|
||||
});
|
||||
if let Some(window) = web_sys::window() {
|
||||
for event in ["beforeinstallprompt", "appinstalled"] {
|
||||
let ctx = ctx.clone();
|
||||
let redraw = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || ctx.request_repaint());
|
||||
let _ = window.add_event_listener_with_callback(event, redraw.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
redraw.forget();
|
||||
}
|
||||
}
|
||||
let offered = move || if offer().is_some() { Some(Offer::Prompt) } else { steps.map(Offer::Steps) };
|
||||
InstallApp { offer: Box::new(offered), prompt: Box::new(prompt) }
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
//!
|
||||
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the
|
||||
//! sample part.
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2 (remembered until
|
||||
//! `?webgpu`), `?sample` opens the sample part.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod install;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
@@ -73,6 +75,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);
|
||||
@@ -392,6 +400,29 @@ mod web {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Where `?webgl` is remembered. The installed app starts from its manifest's start URL, which
|
||||
/// has no query, so the choice has to outlive the page it was made on.
|
||||
const WEBGL_KEY: &str = "solidcraft.webgl";
|
||||
|
||||
/// Render with WebGL2: asked for now (`?webgl`, remembered) or before (until `?webgpu`).
|
||||
fn webgl() -> bool {
|
||||
let storage = web_sys::window().and_then(|w| w.local_storage().ok().flatten());
|
||||
let q = query();
|
||||
if q.contains("webgl") {
|
||||
if let Some(s) = &storage {
|
||||
let _ = s.set_item(WEBGL_KEY, "1");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if q.contains("webgpu") {
|
||||
if let Some(s) = &storage {
|
||||
let _ = s.remove_item(WEBGL_KEY);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
storage.and_then(|s| s.get_item(WEBGL_KEY).ok().flatten()).is_some()
|
||||
}
|
||||
|
||||
/// Fetch font bytes asynchronously through the browser.
|
||||
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
@@ -416,7 +447,7 @@ mod web {
|
||||
return;
|
||||
};
|
||||
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
|
||||
if query().contains("webgl")
|
||||
if webgl()
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
@@ -434,13 +465,14 @@ 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 }))))
|
||||
})),
|
||||
install_app: Some(crate::install::service(&cc.egui_ctx)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SolveApp::new(Session::default(), services);
|
||||
@@ -459,6 +491,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), src/native_crash.rs (Windows exception filter) and src/native_frame.rs
|
||||
# (Windows resize border) can allow it: none 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,47 @@ 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"] }
|
||||
# API keys of the agent chat's API agents (docs/agent-chat.md) live in the system keychain:
|
||||
# Keychain on macOS, Credential Manager on Windows, the Secret Service (pure-Rust D-Bus) elsewhere.
|
||||
keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] }
|
||||
|
||||
[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"] }
|
||||
# The main window's handle for src/native_frame.rs (already in the tree through eframe and winit).
|
||||
raw-window-handle = "0.6"
|
||||
# Native crash capture (src/native_crash.rs): the exception filter, the faulting module, a minidump.
|
||||
# The window's resize border and DPI size (src/native_frame.rs): the window procedure, hit test, DPI.
|
||||
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",
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[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"] }
|
||||
|
||||
@@ -18,8 +18,9 @@ const MAX_CONNECTIONS: usize = 16;
|
||||
const MAX_BAD_LINES: usize = 8;
|
||||
|
||||
static OPEN: AtomicUsize = AtomicUsize::new(0);
|
||||
/// The token file this launch wrote, removed when the app ends.
|
||||
static TOKEN_FILE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
/// The token files this launch wrote (its `--control` port, the agent chat's endpoint), removed
|
||||
/// when the app ends.
|
||||
static TOKEN_FILES: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// A new random token: 32 bytes from the operating system, as hex.
|
||||
fn new_token() -> Option<String> {
|
||||
@@ -30,30 +31,55 @@ fn new_token() -> Option<String> {
|
||||
|
||||
/// Remove this launch's token file (at exit).
|
||||
pub fn remove_token_file() {
|
||||
if let Some(p) = TOKEN_FILE.get() {
|
||||
let _ = std::fs::remove_file(p);
|
||||
if let Ok(files) = TOKEN_FILES.lock() {
|
||||
for p in files.iter() {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// Every connection must show the token before anything runs (docs/control-protocol.md).
|
||||
let Some(token) = control_auth::env_token().or_else(new_token) else {
|
||||
eprintln!("solidcraft: control server not started: no random token available");
|
||||
return rx;
|
||||
match control_auth::env_token().or_else(new_token) {
|
||||
Some(token) => {
|
||||
listen(port, token, tx, ctx, false);
|
||||
}
|
||||
None => eprintln!("solidcraft: control server not started: no random token available"),
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
/// The agent chat's own endpoint for its agent's MCP server (docs/agent-chat.md): a free loopback
|
||||
/// port with a fresh token of its own (never `SOLIDCRAFT_CONTROL_TOKEN`), written to the usual
|
||||
/// token file, where the MCP server finds it, so the token never passes through the agent. Its
|
||||
/// requests go to `tx`, where the chat's gate reads them; one may wait long for the user's Allow.
|
||||
pub fn listen_agent(tx: Sender<ControlRequest>, ctx: egui::Context) -> Option<u16> {
|
||||
let Some(token) = new_token() else {
|
||||
eprintln!("solidcraft: agent chat endpoint not started: no random token available");
|
||||
return None;
|
||||
};
|
||||
listen(0, token, tx, ctx, true)
|
||||
}
|
||||
|
||||
/// Listen on `port` (0: a free port the system picks) with `token`; the port it listens on.
|
||||
/// `patient`: every request may wait as long as a batch (a person decides on it).
|
||||
fn listen(port: u16, token: String, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) -> Option<u16> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("solidcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
|
||||
match control_auth::token_path(port) {
|
||||
Some(path) => match control_auth::write_token_file(&path, &token) {
|
||||
Ok(()) => {
|
||||
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
|
||||
let _ = TOKEN_FILE.set(path);
|
||||
if let Ok(mut files) = TOKEN_FILES.lock() {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
"solidcraft: control server listening on 127.0.0.1:{port}, but its token file {} could not be written ({e}); \
|
||||
@@ -78,12 +104,12 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let ctx = ctx.clone();
|
||||
let token = token.clone();
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &token, tx, ctx);
|
||||
serve(stream, &token, tx, ctx, patient);
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
});
|
||||
rx
|
||||
Some(port)
|
||||
}
|
||||
|
||||
/// One line without its newline, or `None` at the end of the stream. A client that sends
|
||||
@@ -134,7 +160,7 @@ fn refuse(out: &mut TcpStream, reader: &mut BufReader<TcpStream>) {
|
||||
let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink());
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
@@ -168,7 +194,7 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
// A batch replaces many short requests: the whole of it may take long (#743).
|
||||
let wait = Duration::from_secs(if method == "engine.batch" { 3600 } else { 60 });
|
||||
let wait = Duration::from_secs(if patient || method == "engine.batch" { 3600 } else { 60 });
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
@@ -223,7 +249,7 @@ mod tests {
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || serve(stream, token, tx, egui::Context::default()));
|
||||
std::thread::spawn(move || serve(stream, token, tx, egui::Context::default(), false));
|
||||
}
|
||||
});
|
||||
let session = |lines: &[String]| {
|
||||
|
||||
+359
-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,48 @@
|
||||
//! 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 = "windows")]
|
||||
mod native_frame;
|
||||
#[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 +63,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);
|
||||
}
|
||||
@@ -55,15 +86,27 @@ impl eframe::App for App {
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs());
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
// The agent chat's agent and everything it started end with the app.
|
||||
solidcraft_ui_egui::agent_chat::shutdown(&mut self.solve);
|
||||
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 +133,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 +153,16 @@ 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)),
|
||||
// Already an installed app: only the browser build offers to install.
|
||||
install_app: None,
|
||||
agent_http: Some(std::sync::Arc::new(agent_http)),
|
||||
secrets: Some(std::sync::Arc::new(Keychain)),
|
||||
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 +183,144 @@ 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(())
|
||||
}
|
||||
|
||||
/// HTTP for the agent chat's API agents (OpenAI-compatible endpoints, docs/agent-chat.md): to the
|
||||
/// URL the user configured, which may be plain HTTP on this computer (Ollama, LM Studio). No
|
||||
/// redirects (a key must not follow one elsewhere), at most 16 MB back.
|
||||
fn agent_http(r: &solidcraft_ui_egui::agent_chat::backend::HttpRequest) -> Result<(u16, Vec<u8>), String> {
|
||||
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)
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(Some(Duration::from_secs(r.timeout_s.clamp(1, 900))))
|
||||
.timeout_connect(Some(Duration::from_secs(10)))
|
||||
.user_agent("SolidCraft")
|
||||
.max_redirects(0)
|
||||
.build()
|
||||
.into();
|
||||
let mut response = if r.method == "POST" {
|
||||
let mut req = agent.post(&r.url);
|
||||
for (k, v) in &r.headers {
|
||||
req = req.header(k, v);
|
||||
}
|
||||
req.send(r.body.as_deref().unwrap_or_default())
|
||||
} else {
|
||||
let mut req = agent.get(&r.url);
|
||||
for (k, v) in &r.headers {
|
||||
req = req.header(k, v);
|
||||
}
|
||||
req.call()
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response.body_mut().with_config().limit(16 << 20).read_to_vec().map_err(|e| e.to_string())?;
|
||||
Ok((status, body))
|
||||
}
|
||||
|
||||
/// API keys in the system keychain (service "SolidCraft agent chat", one entry per agent).
|
||||
struct Keychain;
|
||||
|
||||
impl Keychain {
|
||||
fn entry(id: &str) -> Result<keyring::Entry, String> {
|
||||
keyring::Entry::new("SolidCraft agent chat", id).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl solidcraft_ui_egui::agent_chat::backend::Secrets for Keychain {
|
||||
fn get(&self, id: &str) -> Result<Option<String>, String> {
|
||||
match Self::entry(id)?.get_password() {
|
||||
Ok(p) => Ok(Some(p)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
fn set(&self, id: &str, secret: &str) -> Result<(), String> {
|
||||
Self::entry(id)?.set_password(secret).map_err(|e| e.to_string())
|
||||
}
|
||||
fn delete(&self, id: &str) -> Result<(), String> {
|
||||
match Self::entry(id)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +368,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 +421,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 +430,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 +449,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 +484,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 +522,44 @@ 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;
|
||||
// Windows: the OS hit test offers the resize border, at the window's DPI, from the
|
||||
// first message (#1381).
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
app.native_resize_border = CUSTOM_TITLEBAR && native_frame::install(cc);
|
||||
if CUSTOM_TITLEBAR && !app.native_resize_border {
|
||||
eprintln!("SolidCraft window: no native resize border; egui edge zones instead");
|
||||
}
|
||||
}
|
||||
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();
|
||||
@@ -312,6 +572,10 @@ fn main() -> eframe::Result {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
// The agent chat's MCP bridge reaches the app through an endpoint of its own, started
|
||||
// on a free port the first time the chat connects (docs/agent-chat.md).
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
app.agent_chat.control_starter = Some(Box::new(move |tx| control_server::listen_agent(tx, ctx.clone())));
|
||||
if sample {
|
||||
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
|
||||
app.set_status(format!("sample: {e}"), true);
|
||||
@@ -320,9 +584,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 +608,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,177 @@
|
||||
//! Windows: the resize border of the window without OS decorations (#1381).
|
||||
//!
|
||||
//! The window keeps its native thick frame (`WS_THICKFRAME`, so Aero Snap, Win+arrows and the
|
||||
//! OS's own move and size loops work) but winit draws no frame: its `WM_NCCALCSIZE` makes the
|
||||
//! whole window client area, and every point then hit-tests as `HTCLIENT`. This module puts the
|
||||
//! resize border back by answering `WM_NCHITTEST` itself: within Windows' resize-border width
|
||||
//! for the window's current DPI from each edge (`solidcraft_ui_egui::titlebar::resize_hit`;
|
||||
//! 8 px at 100 %, corners win), it returns `HTLEFT`, `HTTOPRIGHT`, … So from the first message
|
||||
//! the OS shows its resize cursors and runs its own resize, whatever egui has laid out, whatever
|
||||
//! the scale; nothing waits for a first frame or a first size change.
|
||||
//!
|
||||
//! It also answers `WM_GETDPISCALEDSIZE` with the size the window will really have at the new
|
||||
//! DPI (the same logical size, as winit computes it), so the rect Windows suggests while the
|
||||
//! title bar is dragged onto a monitor with another scale is the rect winit then applies: the
|
||||
//! window rescales once at the crossing and stays under the pointer, instead of the OS's move
|
||||
//! loop and winit disagreeing about a frame the window doesn't have.
|
||||
//!
|
||||
//! Replacing a window procedure has no safe API, hence the `unsafe_code` allowance on this
|
||||
//! module (the app crate denies it; see `native_crash.rs` and `open_documents.rs` for the others).
|
||||
//! The procedure is installed once, on the thread that owns the window, for the main window only,
|
||||
//! and calls the previous one (winit's) for everything it doesn't answer.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use solidcraft_ui_egui::titlebar;
|
||||
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, SIZE, WPARAM};
|
||||
use windows_sys::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
CallWindowProcW, GWL_STYLE, GWLP_WNDPROC, GetClientRect, GetWindowLongPtrW, GetWindowRect, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTLEFT,
|
||||
HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, IsIconic, IsZoomed, SM_CXPADDEDBORDER, SM_CXSIZEFRAME, SetWindowLongPtrW, WM_GETDPISCALEDSIZE,
|
||||
WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME,
|
||||
};
|
||||
|
||||
/// The window procedure ours replaced (winit's), as an address; 0 before [`install`].
|
||||
static PREVIOUS: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
/// Answer the main window's hit test and DPI-size questions from now on. Call it once, on the
|
||||
/// thread that created the window (eframe's app creator). `false` if it isn't a Win32 window.
|
||||
pub fn install(window: &impl HasWindowHandle) -> bool {
|
||||
let Ok(handle) = window.window_handle() else { return false };
|
||||
let RawWindowHandle::Win32(w) = handle.as_raw() else { return false };
|
||||
let hwnd = w.hwnd.get() as HWND;
|
||||
if PREVIOUS.load(Ordering::SeqCst) != 0 {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: `hwnd` is the live window eframe just created on this thread; `frame_proc` has the
|
||||
// WNDPROC signature and stays valid for the life of the process. SetWindowLongPtrW returns
|
||||
// the previous procedure, which `frame_proc` calls for every other message.
|
||||
let previous = unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, frame_proc as *const () as isize as _) };
|
||||
// `as _`: on 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
|
||||
// returns i32.
|
||||
if previous == 0 {
|
||||
return false;
|
||||
}
|
||||
PREVIOUS.store(previous as _, Ordering::SeqCst);
|
||||
true
|
||||
}
|
||||
|
||||
/// The procedure ours replaced.
|
||||
fn previous() -> WNDPROC {
|
||||
let p = PREVIOUS.load(Ordering::SeqCst);
|
||||
// SAFETY: a nonzero value is the WNDPROC that SetWindowLongPtrW returned in `install`.
|
||||
(p != 0).then(|| unsafe { std::mem::transmute::<isize, unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT>(p) })
|
||||
}
|
||||
|
||||
unsafe extern "system" fn frame_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
let prev = previous();
|
||||
// SAFETY: the arguments are the message as Windows delivered it to this window.
|
||||
let call_previous = || unsafe { CallWindowProcW(prev, hwnd, msg, wparam, lparam) };
|
||||
match msg {
|
||||
WM_NCHITTEST => {
|
||||
let default = call_previous();
|
||||
// Screen coordinates, signed (monitors left of or above the primary one).
|
||||
let (x, y) = (i32::from(lparam as u16 as i16), i32::from((lparam >> 16) as u16 as i16));
|
||||
// The border, else what winit and the system say (`HTCLIENT` everywhere else).
|
||||
border_hit(hwnd, x, y).map_or(default, |hit| hit as LRESULT)
|
||||
}
|
||||
WM_GETDPISCALEDSIZE => {
|
||||
let new_dpi = wparam as u32;
|
||||
// SAFETY: for this message lParam points to a SIZE the system reads back.
|
||||
match (resizable(hwnd), client_size(hwnd), unsafe { (lparam as *mut SIZE).as_mut() }) {
|
||||
(true, Some(size), Some(out)) => {
|
||||
// SAFETY: a window handle the system passed in.
|
||||
let old_dpi = unsafe { GetDpiForWindow(hwnd) };
|
||||
match titlebar::dpi_scaled_size(size, old_dpi, new_dpi) {
|
||||
Some((cx, cy)) => {
|
||||
*out = SIZE { cx, cy };
|
||||
1
|
||||
}
|
||||
None => call_previous(),
|
||||
}
|
||||
}
|
||||
_ => call_previous(),
|
||||
}
|
||||
}
|
||||
WM_NCDESTROY => {
|
||||
// Hand the window back to winit's procedure for its last message.
|
||||
if prev.is_some() {
|
||||
// SAFETY: restores the procedure this window had before `install`.
|
||||
unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, PREVIOUS.load(Ordering::SeqCst) as _) };
|
||||
}
|
||||
call_previous()
|
||||
}
|
||||
_ => call_previous(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The window can be resized by its border now: it has the thick frame (not fullscreen, which
|
||||
/// drops it) and is neither maximized nor minimized.
|
||||
fn resizable(hwnd: HWND) -> bool {
|
||||
// SAFETY: plain queries on a window handle the system passed in.
|
||||
unsafe {
|
||||
let style = GetWindowLongPtrW(hwnd, GWL_STYLE) as u32;
|
||||
style & WS_THICKFRAME != 0 && IsZoomed(hwnd) == 0 && IsIconic(hwnd) == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// The client area's size in pixels (the whole window: winit gives it no frame).
|
||||
fn client_size(hwnd: HWND) -> Option<(i32, i32)> {
|
||||
let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
// SAFETY: `r` is a valid RECT to fill.
|
||||
(unsafe { GetClientRect(hwnd, &mut r) } != 0).then_some((r.right - r.left, r.bottom - r.top))
|
||||
}
|
||||
|
||||
/// Windows' resize border at the window's DPI: the sizing frame plus its padding, as Windows
|
||||
/// itself sizes it, or 8 px scaled when the system has no answer.
|
||||
fn border_px(hwnd: HWND) -> f32 {
|
||||
// SAFETY: plain queries.
|
||||
let (dpi, metric) = unsafe {
|
||||
let dpi = GetDpiForWindow(hwnd);
|
||||
(dpi, GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi) + GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi))
|
||||
};
|
||||
let scale = if dpi > 0 { dpi as f32 / 96.0 } else { 1.0 };
|
||||
if metric > 0 { metric as f32 } else { titlebar::resize_border_px(scale) }
|
||||
}
|
||||
|
||||
/// The `HT*` resize code at screen point (`x`, `y`), if it is on the border.
|
||||
fn border_hit(hwnd: HWND, x: i32, y: i32) -> Option<u32> {
|
||||
if !resizable(hwnd) {
|
||||
return None;
|
||||
}
|
||||
let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
// SAFETY: `r` is a valid RECT to fill.
|
||||
if unsafe { GetWindowRect(hwnd, &mut r) } == 0 {
|
||||
return None;
|
||||
}
|
||||
let w = egui::Rect::from_min_max(egui::pos2(r.left as f32, r.top as f32), egui::pos2(r.right as f32, r.bottom as f32));
|
||||
titlebar::resize_hit(w, egui::pos2(x as f32, y as f32), border_px(hwnd)).map(hit_code)
|
||||
}
|
||||
|
||||
fn hit_code(d: egui::ResizeDirection) -> u32 {
|
||||
use egui::ResizeDirection::*;
|
||||
match d {
|
||||
North => HTTOP,
|
||||
South => HTBOTTOM,
|
||||
West => HTLEFT,
|
||||
East => HTRIGHT,
|
||||
NorthWest => HTTOPLEFT,
|
||||
NorthEast => HTTOPRIGHT,
|
||||
SouthWest => HTBOTTOMLEFT,
|
||||
SouthEast => HTBOTTOMRIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_direction_has_its_own_hit_code() {
|
||||
use egui::ResizeDirection::*;
|
||||
let codes: Vec<u32> = [North, South, West, East, NorthWest, NorthEast, SouthWest, SouthEast].into_iter().map(hit_code).collect();
|
||||
assert_eq!(codes, vec![HTTOP, HTBOTTOM, HTLEFT, HTRIGHT, HTTOPLEFT, HTTOPRIGHT, HTBOTTOMLEFT, HTBOTTOMRIGHT]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -75,7 +75,7 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
|
||||
| Linux, Nix, deb/rpm, tar, Flatpak, Snap | Copy canonical hicolor images; icon name stays `ai.storyteller.solidcraft` |
|
||||
| AppImage | Uses the Linux 256 px image and `.DirIcon` alias |
|
||||
| FreeBSD | Shares Linux hicolor images and freedesktop metadata |
|
||||
| Web | HTML/Trunk stays in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/` and uses the collected CSS; no favicon/PWA icon currently exists |
|
||||
| Web | HTML/Trunk and `manifest.webmanifest` stay in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/`, uses the collected CSS, and copies the Linux 32/256/512 px images to `icons/` for the favicon and installed app |
|
||||
| Docs and link previews | `xtask book` copies the shared card to the existing `/solidcraft/og.png` public path |
|
||||
|
||||
## Code and provenance
|
||||
|
||||
@@ -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,19 @@ 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);
|
||||
}
|
||||
// Agent chat: a speech bubble with three dots.
|
||||
"agent_chat" => {
|
||||
pen.closed(&[(4.0, 5.0), (20.0, 5.0), (20.0, 16.0), (11.0, 16.0), (6.5, 20.0), (7.5, 16.0), (4.0, 16.0)], ink);
|
||||
for x in [8.5, 12.0, 15.5] {
|
||||
pen.dot(x, 10.5, ink);
|
||||
}
|
||||
}
|
||||
// Help: a question mark in a ring.
|
||||
"help" => {
|
||||
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
|
||||
@@ -472,7 +484,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 +531,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 +539,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 +578,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 +596,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 +831,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 +840,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 +851,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 +985,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 +1001,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 +1044,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 +1055,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 +1072,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 +1082,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 +1092,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 +1106,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 +1142,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 +1162,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 +1192,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 +1211,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 +1258,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 +1285,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 +1360,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);
|
||||
}
|
||||
|
||||
+426
-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",
|
||||
@@ -14,6 +26,31 @@
|
||||
"dialog.selection_guidance": "Wählen Sie eine Eingabe aus, um sie zu ändern. Klicken Sie auf ausgewählte Objekte, um sie zu entfernen; Strg/Cmd-Klick schaltet sie ebenfalls um. Prüfen Sie die Vorschau und drücken Sie dann OK.",
|
||||
"document.save_changes": "Änderungen in {name} speichern?",
|
||||
"document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.",
|
||||
"home.documentation": "Dokumentation",
|
||||
"home.in_this_browser": "in diesem Browser",
|
||||
"home.missing": "fehlt",
|
||||
"home.open": "Öffnen…",
|
||||
"home.opened_days_ago": "Vor {count} Tag(en) geöffnet",
|
||||
"home.opened_hours_ago": "Vor {count} Std. geöffnet",
|
||||
"home.opened_just_now": "Gerade eben geöffnet",
|
||||
"home.opened_minutes_ago": "Vor {count} Min. geöffnet",
|
||||
"home.opening": "wird geöffnet…",
|
||||
"home.recent": "Zuletzt verwendet",
|
||||
"home.report_issue": "Problem melden",
|
||||
"home.samples": "Beispiele",
|
||||
"home.whats_new": "Neuigkeiten",
|
||||
"sample.bracket.about": "Grundflansch mit Kantenflanschen und einer Umkantung nach einer 2-mm-Blechregel",
|
||||
"sample.bracket.name": "Blechwinkel",
|
||||
"sample.enclosure.about": "Ausgehöhlter Block mit Lippe, Schraubenerhöhungen, Auflage und Rastverbindung",
|
||||
"sample.enclosure.name": "Kunststoffgehäuse",
|
||||
"sample.flange.about": "Nabe und Flansch mit einem parametergesteuerten radialen Muster",
|
||||
"sample.flange.name": "Schraubflansch",
|
||||
"sample.hinge.about": "Zwei Komponenten und ein Drehgelenk, auf 60° angetrieben",
|
||||
"sample.hinge.name": "Scharnierbaugruppe",
|
||||
"sample.plate.about": "Parametrische Platte mit Vollrundungen, Bohrungen und einer aufgebohrten Erhöhung",
|
||||
"sample.plate.name": "Beispielplatte",
|
||||
"sample.vents.about": "Langlöcher in einem Rechteckmuster: Zeilen, Spalten und Abstand sind Parameter",
|
||||
"sample.vents.name": "Lüftungsplatte",
|
||||
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen",
|
||||
"settings.msaa_reset": "{setting}: {requested}× wird von diesem Grafikgerät nicht unterstützt; jetzt {reset}× (unterstützt: {supported}).",
|
||||
"settings.msaa_unsupported": "Von diesem Grafikgerät nicht unterstützt",
|
||||
@@ -29,6 +66,147 @@
|
||||
"ui.active_component": "Aktive Komponente",
|
||||
"ui.agent_cursor": "Agent-Cursor",
|
||||
"ui.agent_cursor_speed": "Agent-Cursor-Geschwindigkeit",
|
||||
"ui.agent_chat": "Agenten-Chat",
|
||||
"ui.agent_chat_command": "Agentenbefehl",
|
||||
"ui.agent_chat_desktop_only": "Der Agenten-Chat braucht die Desktop-App.",
|
||||
"ui.agent_chat_side": "Seite des Agenten-Chats",
|
||||
"ui.agent_chat_left": "Links",
|
||||
"ui.agent_chat_right": "Rechts",
|
||||
"ui.agent_chat_turn_limit": "Änderungen pro Nachricht",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft sendet nichts irgendwohin: Der Agent, den Sie starten, spricht unter Ihrem eigenen Konto mit seinem eigenen Dienst. SolidCraft sieht und speichert nie einen Schlüssel oder ein Passwort.",
|
||||
"ui.agent_chat_connect": "Verbinden",
|
||||
"ui.agent_chat_stop": "Beenden",
|
||||
"ui.agent_chat_stop_tip": "Den Agenten und alles, was er gestartet hat, beenden",
|
||||
"ui.agent_chat_close": "Schließen",
|
||||
"ui.agent_chat_minimize": "Zu einer Leiste minimieren",
|
||||
"ui.agent_chat_dock_left": "Links andocken",
|
||||
"ui.agent_chat_dock_right": "Rechts andocken",
|
||||
"ui.agent_chat_send": "Senden",
|
||||
"ui.agent_chat_cancel": "Abbrechen",
|
||||
"ui.agent_chat_refresh": "Aktualisieren",
|
||||
"ui.agent_chat_pick": "Agent",
|
||||
"ui.agent_chat_custom_hint": "Jedes Programm, das das Agent Client Protocol über stdin und stdout spricht. Es läuft ohne Shell.",
|
||||
"ui.agent_chat_not_installed": "Nicht installiert",
|
||||
"ui.agent_chat_none_found": "Kein Coding-Agent gefunden. Installieren Sie einen davon und klicken Sie dann auf Aktualisieren:",
|
||||
"ui.agent_chat_detecting": "Suche nach Agenten…",
|
||||
"ui.agent_chat_signed_in": "angemeldet",
|
||||
"ui.agent_chat_key_in_env": "Schlüssel gesetzt",
|
||||
"ui.agent_chat_not_signed_in": "nicht angemeldet",
|
||||
"ui.agent_chat_state_off": "Nicht verbunden",
|
||||
"ui.agent_chat_state_starting": "Startet…",
|
||||
"ui.agent_chat_state_ready": "Bereit",
|
||||
"ui.agent_chat_state_busy": "Arbeitet…",
|
||||
"ui.agent_chat_state_auth": "Anmeldung nötig",
|
||||
"ui.agent_chat_state_exited": "Agent beendet",
|
||||
"ui.agent_chat_changes_of": "{n}/{limit} Änderungen",
|
||||
"ui.agent_chat_auth_title": "Beim Agenten anmelden",
|
||||
"ui.agent_chat_auth_needed": "Der Agent bittet Sie, sich anzumelden. Sie melden sich beim Agenten selbst an; SolidCraft sieht Ihr Passwort oder Ihre Schlüssel nie.",
|
||||
"ui.agent_chat_auth_none": "Der Agent bietet hier keine Anmeldung an: Melden Sie sich so an, wie es seine Dokumentation beschreibt.",
|
||||
"ui.agent_chat_auth_terminal": "Führen Sie dies in einem Terminal aus und klicken Sie dann erneut auf Verbinden:",
|
||||
"ui.agent_chat_auth_agent": "Mit {name} anmelden",
|
||||
"ui.agent_chat_auth_env": "Setzen Sie {vars} in Ihrer Umgebung und starten Sie SolidCraft neu.",
|
||||
"ui.agent_chat_auth_retry": "Ich bin angemeldet: erneut verbinden",
|
||||
"ui.agent_chat_copy": "Kopieren",
|
||||
"ui.agent_chat_permission": "Der Agent möchte ausführen: {title}",
|
||||
"ui.agent_chat_answered": "Antwort: {option}",
|
||||
"ui.agent_chat_approval": "Darf der Agent das tun?",
|
||||
"ui.agent_chat_allow": "Erlauben",
|
||||
"ui.agent_chat_deny": "Ablehnen",
|
||||
"ui.agent_chat_allowed": "Erlaubt",
|
||||
"ui.agent_chat_denied": "Abgelehnt",
|
||||
"ui.agent_chat_changes": "Änderungen: {n}, ein Rückgängig-Schritt",
|
||||
"ui.agent_chat_undo_changes": "Diese Änderungen rückgängig machen",
|
||||
"ui.agent_chat_undone": "(rückgängig gemacht)",
|
||||
"ui.agent_chat_undo_label": "Agenten-Chat: {message}",
|
||||
"ui.agent_chat_limit": "Der Agent hat die Grenze von {limit} Änderungen für eine Nachricht erreicht. Senden Sie eine weitere Nachricht, damit er weitermacht.",
|
||||
"ui.agent_chat_refused": "Für den Agenten nicht erlaubt: {reason}",
|
||||
"ui.agent_chat_wait_turn": "Der Agent arbeitet noch an der letzten Nachricht: Warten Sie oder klicken Sie auf Abbrechen.",
|
||||
"ui.agent_chat_placeholder": "Bitten Sie den Agenten, das Design zu ändern…",
|
||||
"ui.agent_chat_attach_view": "Ansicht anhängen",
|
||||
"ui.agent_chat_attach_view_tip": "Ein Bild der aktuellen Ansicht mit der Nachricht senden",
|
||||
"ui.agent_chat_no_images": "Dieser Agent nimmt keine Bilder an",
|
||||
"ui.agent_chat_view_attached": "Ansicht angehängt",
|
||||
"ui.agent_chat_no_cli": "solidcraft-cli wurde neben der App nicht gefunden, daher kann der Agent das Design nicht erreichen.",
|
||||
"ui.agent_chat_no_control": "Der Endpunkt des Agenten-Chats ist nicht gestartet, daher kann der Agent das Design nicht erreichen.",
|
||||
"ui.agent_chat_exited": "Der Agent wurde beendet.",
|
||||
"ui.agent_chat_failed_start": "Der Agent konnte nicht starten: {error}",
|
||||
"ui.agent_chat_intro": "Chatten Sie mit einem Coding-Agenten, den Sie schon nutzen, oder einem Modell-Endpunkt Ihrer Wahl. Er arbeitet über die Werkzeuge von SolidCraft am geöffneten Design: Seine Änderungen erscheinen sofort und lassen sich pro Nachricht in einem Schritt rückgängig machen. Was er ohne Nachfrage darf, legen Sie unter Einstellungen > KI fest.",
|
||||
"ui.agent_chat_privacy": "Nichts geht irgendwohin außer an den Agenten, den Sie unter Ihrem eigenen Konto ausführen.",
|
||||
"ui.agent_chat_none_found_short": "Kein Agent gefunden",
|
||||
"ui.ai_add": "Hinzufügen",
|
||||
"ui.ai_add_agent": "Agent hinzufügen…",
|
||||
"ui.ai_add_agent_title": "Agent hinzufügen",
|
||||
"ui.ai_add_api": "OpenAI-kompatible API",
|
||||
"ui.ai_add_custom": "Eigener Befehl",
|
||||
"ui.ai_added": "hinzugefügt",
|
||||
"ui.ai_add_installed": "Installierter Agent",
|
||||
"ui.ai_add_installed_note": "Ein eigener Eintrag für einen installierten Agenten, zum Beispiel mit anderen Argumenten. Installierte Agenten erscheinen von selbst.",
|
||||
"ui.ai_agent_mode_tip": "Der eigene Modus des Agenten: Seine eigenen Werkzeuge (Shell, Dateiänderungen) folgen ihm. Die Werkzeuge von SolidCraft folgen den Berechtigungen unter Einstellungen > KI.",
|
||||
"ui.ai_agents": "Agenten",
|
||||
"ui.ai_always_allow": "Immer erlauben",
|
||||
"ui.ai_always_allowed": "Immer erlaubt",
|
||||
"ui.ai_always_allowed_none": "Noch nichts ist immer erlaubt. Antworten „Immer erlauben“ im Chat erscheinen hier.",
|
||||
"ui.ai_always_allow_tip": "{tool} für diesen Agenten ab jetzt ohne Nachfrage erlauben",
|
||||
"ui.ai_auto_allowed": "Automatisch erlaubt: {title} ({why})",
|
||||
"ui.ai_auto_mode": "Ihre Berechtigungseinstellung",
|
||||
"ui.ai_auto_ours": "SolidCraft prüft seine eigenen Werkzeuge selbst",
|
||||
"ui.ai_auto_remembered": "Sie haben „Immer erlauben“ gewählt",
|
||||
"ui.ai_bad_url": "Geben Sie eine URL ein, die mit http:// oder https:// beginnt",
|
||||
"ui.ai_base_url": "Basis-URL",
|
||||
"ui.ai_button": "KI",
|
||||
"ui.ai_copy_suffix": "Kopie",
|
||||
"ui.ai_custom_url": "Eigene URL",
|
||||
"ui.ai_default": "Standard",
|
||||
"ui.ai_http_busy": "der Endpunkt ist ausgelastet oder am Limit; versuchen Sie es später",
|
||||
"ui.ai_http_key": "der Endpunkt hat den Schlüssel abgelehnt: prüfen Sie ihn unter Einstellungen > KI",
|
||||
"ui.ai_http_not_found": "Endpunkt oder Modell nicht gefunden: prüfen Sie Basis-URL und Modellnamen",
|
||||
"ui.ai_key": "API-Schlüssel",
|
||||
"ui.ai_keychain_error": "Der Schlüsselbund des Systems hat abgelehnt: {error}",
|
||||
"ui.ai_key_hint": "optional; im Schlüsselbund des Systems gespeichert",
|
||||
"ui.ai_key_needs_https": "Ein Schlüssel wird nur über HTTPS oder an diesen Computer gesendet.",
|
||||
"ui.ai_key_note": "Im Schlüsselbund des Systems gespeichert, nie in den Einstellungen von SolidCraft. Für einen lokalen Server leer lassen.",
|
||||
"ui.ai_mode_all": "Alles erlauben",
|
||||
"ui.ai_mode_all_note": "Nichts fragt vorher. Absturzberichte bleiben tabu, und die Ordnergrenzen gelten weiter.",
|
||||
"ui.ai_mode_all_warning": "Der Agent kann Ihr Design ohne Nachfrage speichern, überschreiben, exportieren und ersetzen. Nutzen Sie das nur mit einem Agenten, dem Sie vertrauen, und mit gespeicherten Designs.",
|
||||
"ui.ai_mode_ask": "Jedes Mal fragen",
|
||||
"ui.ai_mode_ask_note": "Jede Änderung am Design und jede Dateiaktion wartet auf Ihr Erlauben.",
|
||||
"ui.ai_mode_edits": "Modelländerungen automatisch erlauben",
|
||||
"ui.ai_mode_edits_note": "Modellierbefehle laufen ohne Nachfrage; Speichern, Öffnen, Exportieren und andere Datei- oder destruktive Aktionen fragen weiterhin.",
|
||||
"ui.ai_mode_global": "Wie oben eingestellt",
|
||||
"ui.ai_model": "Modell",
|
||||
"ui.ai_modes_scope": "Diese Einstellungen gelten für die Werkzeuge von SolidCraft. Die eigenen Werkzeuge eines Agenten (seine Shell, seine Dateiänderungen) folgen seinem eigenen Modus, den Sie im Kopf des Chat-Panels wählen, wenn der Agent Modi hat.",
|
||||
"ui.ai_name": "Name",
|
||||
"ui.ai_need_command": "Geben Sie den Befehl ein, der den Agenten startet",
|
||||
"ui.ai_need_model": "Geben Sie den Modellnamen ein",
|
||||
"ui.ai_no_answer": "Der Agent hat nicht rechtzeitig geantwortet",
|
||||
"ui.ai_no_http": "Dieser Build kann keine API-Endpunkte erreichen",
|
||||
"ui.ai_no_keychain": "Der Schlüsselbund des Systems ist nicht verfügbar, daher kann kein Schlüssel gespeichert werden. Ein lokaler Server ohne Schlüssel funktioniert trotzdem.",
|
||||
"ui.ai_panel": "Panel",
|
||||
"ui.ai_permissions": "Berechtigungen",
|
||||
"ui.ai_preset": "Vorlage",
|
||||
"ui.ai_remove": "Entfernen",
|
||||
"ui.ai_request_limit": "nach 24 Anfragen für eine Nachricht angehalten; senden Sie eine weitere Nachricht, um fortzufahren",
|
||||
"ui.ai_reset": "Zurücksetzen",
|
||||
"ui.ai_settings": "KI-Einstellungen…",
|
||||
"ui.ai_show_features": "KI-Funktionen anzeigen",
|
||||
"ui.ai_show_features_note": "Der Agenten-Chat: ein Panel, in dem ein Coding-Agent, den Sie schon nutzen, oder ein Modell-Endpunkt Ihrer Wahl am geöffneten Design arbeitet. Aus: keine KI-Schaltfläche, kein Kürzel, kein Menüeintrag, kein Panel, und nichts wird gesucht oder gestartet.",
|
||||
"ui.ai_switch_tip": "Den Agenten für dieses Gespräch wechseln",
|
||||
"ui.ai_test": "Testen",
|
||||
"ui.ai_test_acp_ok": "Antwortet: {agent}",
|
||||
"ui.ai_test_acp_tip": "Startet den Agenten kurz und bittet um eine Sitzung; beendet ihn wieder",
|
||||
"ui.ai_test_api_tip": "Fragt den Endpunkt nach seinen Modellen",
|
||||
"ui.ai_testing": "Teste…",
|
||||
"ui.ai_test_no_model": "Erreicht: {count} Modelle, aber nicht {model}",
|
||||
"ui.ai_test_ok": "Erreicht: {count} Modelle",
|
||||
"ui.ai_toolbar_button": "Schaltfläche in der Werkzeugleiste anzeigen",
|
||||
"ui.ai_toolbar_button_note": "Die KI-Schaltfläche am rechten Ende der Werkzeugleiste. Strg+Umschalt+A und das Ansicht-Menü öffnen das Panel weiterhin.",
|
||||
"ui.ai_tool_done": "fertig",
|
||||
"ui.ai_tool_failed": "fehlgeschlagen",
|
||||
"ui.ai_tool_running": "läuft",
|
||||
"ui.ai_tool_unfold": "Klicken, um das Ergebnis zu zeigen",
|
||||
"ui.ai_tool_waiting": "wartet",
|
||||
"ui.ai_undo_tip": "Alles, was der Agent für diese Nachricht geändert hat, in einem Schritt rückgängig machen",
|
||||
"AI": "KI",
|
||||
"ui.all": "Alle",
|
||||
"ui.analysis": "Analyse",
|
||||
"ui.angle": "Winkel",
|
||||
@@ -37,6 +215,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",
|
||||
@@ -63,10 +251,47 @@
|
||||
"ui.clear_recent": "Zuletzt verwendet leeren",
|
||||
"ui.clear_the_selection": "Auswahl aufheben",
|
||||
"ui.close": "Schließen",
|
||||
"ui.material_review.title": "Physikalische Materialdaten prüfen",
|
||||
"ui.material_review.scope": "Nur Kandidat: nicht zugewiesen oder gespeichert. Numerische Prüfung ist keine technische Zertifizierung.",
|
||||
"ui.material_review.input": "Materialkarte mit Schema 1 einfügen (JSON)",
|
||||
"ui.material_review.review": "Karte prüfen",
|
||||
"ui.material_review.limit": "Die Karte überschreitet die Eingabegrenze von 256 KiB.",
|
||||
"ui.material_review.unknown": "Unbekannt",
|
||||
"ui.material_review.properties": "Eigenschaften",
|
||||
"ui.material_review.conditions": "Angegebene Bedingungen",
|
||||
"ui.material_review.source": "Quelle",
|
||||
"ui.material_review.axes": "Materiallokale Achsen; keine Körperausrichtung zugewiesen.",
|
||||
"ui.material_review.derived": "Abgeleitete isotrope Moduln",
|
||||
"ui.material_review.claims": "Quellen und Lizenzen sind ungeprüfte Angaben. Fehlende Werte bleiben unbekannt.",
|
||||
"ui.material_review.density": "Dichte",
|
||||
"ui.material_review.elasticity": "Elastizität",
|
||||
"ui.material_review.tensile_strength": "Zugfestigkeit",
|
||||
"ui.material_review.compressive_strength": "Druckfestigkeit",
|
||||
"ui.material_review.yield_strength": "Streckgrenze",
|
||||
"ui.material_review.shear_strength": "Scherfestigkeit",
|
||||
"ui.material_review.thermal_conductivity": "Wärmeleitfähigkeit",
|
||||
"ui.material_review.thermal_expansion": "Wärmeausdehnung",
|
||||
"ui.material_review.specific_heat": "Spezifische Wärmekapazität",
|
||||
"ui.material_review.moisture_unit": "kg Wasser / kg Trockenmaterial",
|
||||
"ui.coil": "Schraube",
|
||||
"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 +485,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 +500,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 +614,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 +648,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",
|
||||
@@ -404,6 +671,7 @@
|
||||
"cmd.component_capture_position": "Position erfassen",
|
||||
"cmd.component_create": "Neue Komponente",
|
||||
"cmd.component_delete": "Komponente löschen",
|
||||
"cmd.component_unlock": "Inhalt entsperren",
|
||||
"cmd.component_from_bodies": "Komponenten aus Körpern erstellen",
|
||||
"cmd.component_insert_design": "Konstruktion einfügen",
|
||||
"cmd.component_links": "Verknüpfungen auflisten",
|
||||
@@ -456,6 +724,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,8 +740,10 @@
|
||||
"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_compare": "Körper vergleichen",
|
||||
"cmd.inspect_curvature_comb": "Krümmungskamm-Analyse",
|
||||
"cmd.inspect_curvature_map": "Krümmungskartenanalyse",
|
||||
"cmd.inspect_draft": "Auswinkelanalyse",
|
||||
@@ -495,6 +768,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 +790,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 +816,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",
|
||||
@@ -563,6 +840,8 @@
|
||||
"cmd.sketch_constraint_fix": "Fixieren/Lösen",
|
||||
"cmd.sketch_constraint_midpoint": "Mittelpunkt",
|
||||
"cmd.sketch_construction": "Normal/Konstruktion",
|
||||
"cmd.sketch_copy": "Skizzenelemente kopieren",
|
||||
"cmd.sketch_cut": "Skizzenelemente ausschneiden",
|
||||
"cmd.sketch_delete": "Skizzenentitäten löschen",
|
||||
"cmd.sketch_dimension_text": "Maßtext verschieben",
|
||||
"cmd.sketch_drag": "Skizzengeometrie ziehen",
|
||||
@@ -587,6 +866,7 @@
|
||||
"cmd.sketch_move_point": "Skizzenpunkt ziehen",
|
||||
"cmd.sketch_offset_break": "Versatzverknüpfung trennen",
|
||||
"cmd.sketch_options": "Skizzenpalettenoptionen",
|
||||
"cmd.sketch_paste": "Skizzenelemente einfügen",
|
||||
"cmd.sketch_point3d": "3D-Punkt",
|
||||
"cmd.sketch_polygon_circumscribed": "Umgeschriebenes Polygon",
|
||||
"cmd.sketch_polygon_edge": "Polygon über eine Kante",
|
||||
@@ -637,9 +917,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 +940,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 +982,145 @@
|
||||
"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",
|
||||
"install.menu": "App installieren…",
|
||||
"install.button_tip": "SolidCraft als App installieren: eigenes Fenster, funktioniert auch offline",
|
||||
"install.title": "SolidCraft installieren",
|
||||
"install.steps_intro": "Safari installiert Web-Apps über seine eigenen Menüs:",
|
||||
"install.home_screen_1": "Tippen Sie auf „Teilen“ (im Menü …, falls es nicht in der Symbolleiste ist).",
|
||||
"install.home_screen_2": "Wählen Sie „Zum Home-Bildschirm“.",
|
||||
"install.home_screen_3": "Tippen Sie auf „Hinzufügen“.",
|
||||
"install.home_screen_after": "SolidCraft öffnet sich dann über sein Symbol auf dem Home-Bildschirm, ohne die Leisten von Safari, und funktioniert auch offline.",
|
||||
"install.dock_1": "Wählen Sie Ablage › Zum Dock hinzufügen.",
|
||||
"install.dock_2": "Klicken Sie auf „Hinzufügen“.",
|
||||
"install.dock_after": "SolidCraft öffnet sich dann aus dem Dock in einem eigenen Fenster und funktioniert auch offline.",
|
||||
"install.done": "Fertig",
|
||||
"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": "Die Kamera bewegt sich wie im Konstruieren (Einstellungen › Navigation). 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_too_large": "Über dem Bildlimit: 4096 Pixel pro Seite und 4.194.304 Pixel insgesamt.",
|
||||
"render.image_use_largest": "Größte Größe mit diesem Seitenverhältnis verwenden:",
|
||||
"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.",
|
||||
"Circumference": "Umfang",
|
||||
"Arc length": "Bogenlänge",
|
||||
"Slot width": "Langlochbreite",
|
||||
"Centre to centre": "Mitte zu Mitte",
|
||||
"Overall length": "Gesamtlänge",
|
||||
"Min": "Min",
|
||||
"Max": "Max",
|
||||
"Length": "Länge",
|
||||
"Area": "Fläche"
|
||||
}
|
||||
|
||||
+421
-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})",
|
||||
@@ -25,6 +36,7 @@
|
||||
"cmd.component_capture_position": "Capture Position",
|
||||
"cmd.component_create": "New Component",
|
||||
"cmd.component_delete": "Delete Component",
|
||||
"cmd.component_unlock": "Unlock Contents",
|
||||
"cmd.component_from_bodies": "Create Components from Bodies",
|
||||
"cmd.component_insert_design": "Insert Design",
|
||||
"cmd.component_links": "List Links",
|
||||
@@ -77,6 +89,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,8 +105,10 @@
|
||||
"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_compare": "Compare Bodies",
|
||||
"cmd.inspect_curvature_comb": "Curvature Comb Analysis",
|
||||
"cmd.inspect_curvature_map": "Curvature Map Analysis",
|
||||
"cmd.inspect_draft": "Draft Analysis",
|
||||
@@ -136,6 +153,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 +179,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",
|
||||
@@ -184,6 +203,8 @@
|
||||
"cmd.sketch_constraint_fix": "Fix/UnFix",
|
||||
"cmd.sketch_constraint_midpoint": "MidPoint",
|
||||
"cmd.sketch_construction": "Normal/Construction",
|
||||
"cmd.sketch_copy": "Copy Sketch Entities",
|
||||
"cmd.sketch_cut": "Cut Sketch Entities",
|
||||
"cmd.sketch_delete": "Delete Sketch Entities",
|
||||
"cmd.sketch_dimension_text": "Move Dimension Text",
|
||||
"cmd.sketch_drag": "Drag Sketch Geometry",
|
||||
@@ -208,6 +229,7 @@
|
||||
"cmd.sketch_move_point": "Drag Sketch Point",
|
||||
"cmd.sketch_offset_break": "Break Offset Association",
|
||||
"cmd.sketch_options": "Sketch Palette Options",
|
||||
"cmd.sketch_paste": "Paste Sketch Entities",
|
||||
"cmd.sketch_point3d": "3D Point",
|
||||
"cmd.sketch_polygon_circumscribed": "Circumscribed Polygon",
|
||||
"cmd.sketch_polygon_edge": "Edge Polygon",
|
||||
@@ -259,9 +281,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 +299,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",
|
||||
@@ -284,6 +309,31 @@
|
||||
"dialog.selection_guidance": "Select an input to change it. Click selected objects to remove them; Ctrl/Cmd-click also toggles them. Review the preview, then press OK.",
|
||||
"document.save_changes": "Save changes to {name}?",
|
||||
"document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.",
|
||||
"home.documentation": "Documentation",
|
||||
"home.in_this_browser": "in this browser",
|
||||
"home.missing": "missing",
|
||||
"home.open": "Open…",
|
||||
"home.opened_days_ago": "Opened {count} days ago",
|
||||
"home.opened_hours_ago": "Opened {count} h ago",
|
||||
"home.opened_just_now": "Opened just now",
|
||||
"home.opened_minutes_ago": "Opened {count} min ago",
|
||||
"home.opening": "opening…",
|
||||
"home.recent": "Recent",
|
||||
"home.report_issue": "Report an Issue",
|
||||
"home.samples": "Samples",
|
||||
"home.whats_new": "What's new",
|
||||
"sample.bracket.about": "Base flange with edge flanges and a hem on a 2 mm rule",
|
||||
"sample.bracket.name": "Sheet Metal Bracket",
|
||||
"sample.enclosure.about": "Shelled box with a lip, screw bosses, a rest and a snap fit",
|
||||
"sample.enclosure.name": "Plastic Enclosure",
|
||||
"sample.flange.about": "Hub and flange with a circular pattern driven by a parameter",
|
||||
"sample.flange.name": "Bolted Flange",
|
||||
"sample.hinge.about": "Two components and a revolute joint, driven to 60°",
|
||||
"sample.hinge.name": "Hinge Assembly",
|
||||
"sample.plate.about": "Parametric plate with fillets, holes and a bored boss",
|
||||
"sample.plate.name": "Sample Plate",
|
||||
"sample.vents.about": "Slots in a rectangular pattern: rows, columns and pitch are parameters",
|
||||
"sample.vents.name": "Vent Plate",
|
||||
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts",
|
||||
"settings.msaa_reset": "{setting}: {requested}× isn't supported by this graphics device; it is now {reset}× (supported: {supported}).",
|
||||
"settings.msaa_unsupported": "Not supported by this graphics device",
|
||||
@@ -301,6 +351,147 @@
|
||||
"ui.active_component": "Active component",
|
||||
"ui.agent_cursor": "Agent cursor",
|
||||
"ui.agent_cursor_speed": "Agent cursor speed",
|
||||
"ui.agent_chat": "Agent chat",
|
||||
"ui.agent_chat_command": "Agent command",
|
||||
"ui.agent_chat_desktop_only": "The agent chat needs the desktop app.",
|
||||
"ui.agent_chat_side": "Agent chat side",
|
||||
"ui.agent_chat_left": "Left",
|
||||
"ui.agent_chat_right": "Right",
|
||||
"ui.agent_chat_turn_limit": "Changes per message",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft sends nothing anywhere: the agent you run talks to its own service under your own account. SolidCraft never sees or stores a key or password.",
|
||||
"ui.agent_chat_connect": "Connect",
|
||||
"ui.agent_chat_stop": "Stop",
|
||||
"ui.agent_chat_stop_tip": "Stop the agent and everything it started",
|
||||
"ui.agent_chat_close": "Close",
|
||||
"ui.agent_chat_minimize": "Minimise to a strip",
|
||||
"ui.agent_chat_dock_left": "Dock on the left",
|
||||
"ui.agent_chat_dock_right": "Dock on the right",
|
||||
"ui.agent_chat_send": "Send",
|
||||
"ui.agent_chat_cancel": "Cancel",
|
||||
"ui.agent_chat_refresh": "Refresh",
|
||||
"ui.agent_chat_pick": "Agent",
|
||||
"ui.agent_chat_custom_hint": "Any program that speaks the Agent Client Protocol on stdin and stdout. No shell runs it.",
|
||||
"ui.agent_chat_not_installed": "Not installed",
|
||||
"ui.agent_chat_none_found": "No coding agent found. Install one of these, then press Refresh:",
|
||||
"ui.agent_chat_detecting": "Looking for agents…",
|
||||
"ui.agent_chat_signed_in": "signed in",
|
||||
"ui.agent_chat_key_in_env": "key set",
|
||||
"ui.agent_chat_not_signed_in": "not signed in",
|
||||
"ui.agent_chat_state_off": "Not connected",
|
||||
"ui.agent_chat_state_starting": "Starting…",
|
||||
"ui.agent_chat_state_ready": "Ready",
|
||||
"ui.agent_chat_state_busy": "Working…",
|
||||
"ui.agent_chat_state_auth": "Sign-in needed",
|
||||
"ui.agent_chat_state_exited": "Agent stopped",
|
||||
"ui.agent_chat_changes_of": "{n}/{limit} changes",
|
||||
"ui.agent_chat_auth_title": "Sign in to the agent",
|
||||
"ui.agent_chat_auth_needed": "The agent asks you to sign in. You sign in with the agent itself; SolidCraft never sees your password or keys.",
|
||||
"ui.agent_chat_auth_none": "The agent offers no sign-in method here: sign in the way its own documentation describes.",
|
||||
"ui.agent_chat_auth_terminal": "Run this in a terminal, then press Connect again:",
|
||||
"ui.agent_chat_auth_agent": "Sign in with {name}",
|
||||
"ui.agent_chat_auth_env": "Set {vars} in your environment and restart SolidCraft.",
|
||||
"ui.agent_chat_auth_retry": "I've signed in: connect again",
|
||||
"ui.agent_chat_copy": "Copy",
|
||||
"ui.agent_chat_permission": "The agent asks to run: {title}",
|
||||
"ui.agent_chat_answered": "Answer: {option}",
|
||||
"ui.agent_chat_approval": "Allow the agent to do this?",
|
||||
"ui.agent_chat_allow": "Allow",
|
||||
"ui.agent_chat_deny": "Deny",
|
||||
"ui.agent_chat_allowed": "Allowed",
|
||||
"ui.agent_chat_denied": "Denied",
|
||||
"ui.agent_chat_changes": "Changes: {n}, one undo step",
|
||||
"ui.agent_chat_undo_changes": "Undo these changes",
|
||||
"ui.agent_chat_undone": "(undone)",
|
||||
"ui.agent_chat_undo_label": "Agent chat: {message}",
|
||||
"ui.agent_chat_limit": "The agent reached the limit of {limit} changes for one message. Send another message to let it go on.",
|
||||
"ui.agent_chat_refused": "Not allowed for the agent: {reason}",
|
||||
"ui.agent_chat_wait_turn": "The agent is still working on the last message: wait for it or press Cancel.",
|
||||
"ui.agent_chat_placeholder": "Ask the agent to change the design…",
|
||||
"ui.agent_chat_attach_view": "Attach view",
|
||||
"ui.agent_chat_attach_view_tip": "Send a picture of the current view with the message",
|
||||
"ui.agent_chat_no_images": "This agent doesn't take pictures",
|
||||
"ui.agent_chat_view_attached": "View attached",
|
||||
"ui.agent_chat_no_cli": "solidcraft-cli was not found next to the app, so the agent can't reach the design.",
|
||||
"ui.agent_chat_no_control": "The agent chat's endpoint did not start, so the agent can't reach the design.",
|
||||
"ui.agent_chat_exited": "The agent stopped.",
|
||||
"ui.agent_chat_failed_start": "The agent could not start: {error}",
|
||||
"ui.agent_chat_intro": "Chat with a coding agent you already use, or a model endpoint you choose. It works on the open design through SolidCraft's tools: its changes show as it makes them and undo as one step per message. What it may do without asking is set in Preferences > AI.",
|
||||
"ui.agent_chat_privacy": "Nothing goes anywhere except to the agent you run, under your own account.",
|
||||
"ui.agent_chat_none_found_short": "No agent found",
|
||||
"ui.ai_add": "Add",
|
||||
"ui.ai_add_agent": "Add agent…",
|
||||
"ui.ai_add_agent_title": "Add agent",
|
||||
"ui.ai_add_api": "OpenAI-compatible API",
|
||||
"ui.ai_add_custom": "Custom command",
|
||||
"ui.ai_added": "added",
|
||||
"ui.ai_add_installed": "Installed agent",
|
||||
"ui.ai_add_installed_note": "An entry of your own for an installed agent, for example with other arguments. Installed agents are listed by themselves.",
|
||||
"ui.ai_agent_mode_tip": "The agent's own mode: its own tools (shell, file edits) follow it. SolidCraft's tools follow the permissions in Preferences > AI.",
|
||||
"ui.ai_agents": "Agents",
|
||||
"ui.ai_always_allow": "Always allow",
|
||||
"ui.ai_always_allowed": "Always allowed",
|
||||
"ui.ai_always_allowed_none": "Nothing is always allowed yet. \"Always allow\" answers you give in the chat are listed here.",
|
||||
"ui.ai_always_allow_tip": "Allow {tool} for this agent from now on, without asking",
|
||||
"ui.ai_auto_allowed": "Auto-allowed: {title} ({why})",
|
||||
"ui.ai_auto_mode": "your permission setting",
|
||||
"ui.ai_auto_ours": "SolidCraft checks its own tools itself",
|
||||
"ui.ai_auto_remembered": "you chose Always allow",
|
||||
"ui.ai_bad_url": "Enter a URL that starts with http:// or https://",
|
||||
"ui.ai_base_url": "Base URL",
|
||||
"ui.ai_button": "AI",
|
||||
"ui.ai_copy_suffix": "copy",
|
||||
"ui.ai_custom_url": "Custom URL",
|
||||
"ui.ai_default": "Default",
|
||||
"ui.ai_http_busy": "the endpoint is busy or over its limit; try again later",
|
||||
"ui.ai_http_key": "the endpoint refused the key: check it in Preferences > AI",
|
||||
"ui.ai_http_not_found": "no such endpoint or model: check the base URL and the model name",
|
||||
"ui.ai_key": "API key",
|
||||
"ui.ai_keychain_error": "The system keychain refused: {error}",
|
||||
"ui.ai_key_hint": "optional; kept in the system keychain",
|
||||
"ui.ai_key_needs_https": "A key is only sent over HTTPS, or to this computer.",
|
||||
"ui.ai_key_note": "Stored in the system keychain, never in SolidCraft's settings. Leave it empty for a local server.",
|
||||
"ui.ai_mode_all": "Allow everything",
|
||||
"ui.ai_mode_all_note": "Nothing asks first. Crash reports stay off limits and the folder limits still apply.",
|
||||
"ui.ai_mode_all_warning": "The agent can save, overwrite, export and replace your design without asking. Use this only with an agent you trust, on designs you have saved.",
|
||||
"ui.ai_mode_ask": "Ask every time",
|
||||
"ui.ai_mode_ask_note": "Every change to the design, and every file action, waits for your Allow.",
|
||||
"ui.ai_mode_edits": "Auto-allow model edits",
|
||||
"ui.ai_mode_edits_note": "Modelling commands run without asking; saving, opening, exporting and other file or destructive actions still ask.",
|
||||
"ui.ai_mode_global": "As set above",
|
||||
"ui.ai_model": "Model",
|
||||
"ui.ai_modes_scope": "These settings cover SolidCraft's tools. An agent's own tools (its shell, its file edits) follow the agent's own mode, which you pick in the chat panel's header when the agent has modes.",
|
||||
"ui.ai_name": "Name",
|
||||
"ui.ai_need_command": "Enter the command that starts the agent",
|
||||
"ui.ai_need_model": "Enter the model name",
|
||||
"ui.ai_no_answer": "The agent did not answer in time",
|
||||
"ui.ai_no_http": "This build can't reach API endpoints",
|
||||
"ui.ai_no_keychain": "The system keychain isn't available, so a key can't be stored. A local server without a key still works.",
|
||||
"ui.ai_panel": "Panel",
|
||||
"ui.ai_permissions": "Permissions",
|
||||
"ui.ai_preset": "Preset",
|
||||
"ui.ai_remove": "Remove",
|
||||
"ui.ai_request_limit": "stopped after 24 requests for one message; send another message to go on",
|
||||
"ui.ai_reset": "Reset",
|
||||
"ui.ai_settings": "AI settings…",
|
||||
"ui.ai_show_features": "Show AI features",
|
||||
"ui.ai_show_features_note": "The agent chat: a panel where a coding agent you already use, or a model endpoint you choose, works on the open design. Off: no AI button, shortcut, menu entry or panel, and nothing is looked for or started.",
|
||||
"ui.ai_switch_tip": "Change the agent for this conversation",
|
||||
"ui.ai_test": "Test",
|
||||
"ui.ai_test_acp_ok": "Answers: {agent}",
|
||||
"ui.ai_test_acp_tip": "Starts the agent briefly and asks it for a session; ends it again",
|
||||
"ui.ai_test_api_tip": "Asks the endpoint for its models",
|
||||
"ui.ai_testing": "Testing…",
|
||||
"ui.ai_test_no_model": "Reached: {count} models, but not {model}",
|
||||
"ui.ai_test_ok": "Reached: {count} models",
|
||||
"ui.ai_toolbar_button": "Show button in toolbar",
|
||||
"ui.ai_toolbar_button_note": "The AI button at the right end of the toolbar. Ctrl+Shift+A and the View menu still open the panel.",
|
||||
"ui.ai_tool_done": "done",
|
||||
"ui.ai_tool_failed": "failed",
|
||||
"ui.ai_tool_running": "running",
|
||||
"ui.ai_tool_unfold": "Click to show what it returned",
|
||||
"ui.ai_tool_waiting": "waiting",
|
||||
"ui.ai_undo_tip": "Undo everything the agent changed for this message, as one step",
|
||||
"AI": "AI",
|
||||
"ui.all": "All",
|
||||
"ui.analysis": "Analysis",
|
||||
"ui.angle": "Angle",
|
||||
@@ -309,6 +500,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",
|
||||
@@ -338,10 +539,47 @@
|
||||
"ui.click_selects_whole_bodies": "Click selects whole bodies",
|
||||
"ui.click_then_press_key": "Click, then press the new key",
|
||||
"ui.close": "Close",
|
||||
"ui.material_review.title": "Review Physical Material Data",
|
||||
"ui.material_review.scope": "Candidate only: not assigned or saved. Numerical validation is not engineering certification.",
|
||||
"ui.material_review.input": "Paste a schema-1 material card (JSON)",
|
||||
"ui.material_review.review": "Review card",
|
||||
"ui.material_review.limit": "The card exceeds the 256 KiB input limit.",
|
||||
"ui.material_review.unknown": "Unknown",
|
||||
"ui.material_review.properties": "Properties",
|
||||
"ui.material_review.conditions": "Stated conditions",
|
||||
"ui.material_review.source": "Source",
|
||||
"ui.material_review.axes": "Material-local axes; no body orientation is assigned.",
|
||||
"ui.material_review.derived": "Derived isotropic moduli",
|
||||
"ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.",
|
||||
"ui.material_review.density": "Density",
|
||||
"ui.material_review.elasticity": "Elasticity",
|
||||
"ui.material_review.tensile_strength": "Tensile strength",
|
||||
"ui.material_review.compressive_strength": "Compressive strength",
|
||||
"ui.material_review.yield_strength": "Yield strength",
|
||||
"ui.material_review.shear_strength": "Shear strength",
|
||||
"ui.material_review.thermal_conductivity": "Thermal conductivity",
|
||||
"ui.material_review.thermal_expansion": "Thermal expansion",
|
||||
"ui.material_review.specific_heat": "Specific heat",
|
||||
"ui.material_review.moisture_unit": "kg water / kg dry material",
|
||||
"ui.coil": "Coil",
|
||||
"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 +791,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 +809,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 +987,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 +1177,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 +1211,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 +1239,143 @@
|
||||
"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",
|
||||
"install.menu": "Install app…",
|
||||
"install.button_tip": "Install SolidCraft as an app: it opens in its own window and works offline",
|
||||
"install.title": "Install SolidCraft",
|
||||
"install.steps_intro": "Safari installs web apps from its own menus:",
|
||||
"install.home_screen_1": "Tap Share (in the … menu if it isn't on the toolbar).",
|
||||
"install.home_screen_2": "Choose Add to Home Screen.",
|
||||
"install.home_screen_3": "Tap Add.",
|
||||
"install.home_screen_after": "SolidCraft then opens from its icon on the Home Screen, without Safari's toolbars, and works offline.",
|
||||
"install.dock_1": "Choose File › Add to Dock.",
|
||||
"install.dock_2": "Click Add.",
|
||||
"install.dock_after": "SolidCraft then opens in its own window from the Dock, and works offline.",
|
||||
"install.done": "Done",
|
||||
"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": "The camera moves as in Design (Preferences › Navigation). 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_too_large": "Over the image limit: 4096 pixels per side and 4,194,304 pixels in all.",
|
||||
"render.image_use_largest": "Use the largest size at this aspect:",
|
||||
"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.",
|
||||
"Circumference": "Circumference",
|
||||
"Arc length": "Arc length",
|
||||
"Slot width": "Slot width",
|
||||
"Centre to centre": "Centre to centre",
|
||||
"Overall length": "Overall length",
|
||||
"Min": "Min",
|
||||
"Max": "Max"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+421
-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})",
|
||||
@@ -25,6 +36,7 @@
|
||||
"cmd.component_capture_position": "位置をキャプチャ",
|
||||
"cmd.component_create": "新規コンポーネント",
|
||||
"cmd.component_delete": "コンポーネントを削除",
|
||||
"cmd.component_unlock": "内容のロックを解除",
|
||||
"cmd.component_from_bodies": "ボディからコンポーネントを作成",
|
||||
"cmd.component_insert_design": "デザインを挿入",
|
||||
"cmd.component_links": "リンクを一覧表示",
|
||||
@@ -77,6 +89,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,8 +105,10 @@
|
||||
"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_compare": "ボディを比較",
|
||||
"cmd.inspect_curvature_comb": "曲率コーム解析",
|
||||
"cmd.inspect_curvature_map": "曲率マップ解析",
|
||||
"cmd.inspect_draft": "勾配解析",
|
||||
@@ -136,6 +153,7 @@
|
||||
"cmd.parameters_delete": "パラメータを削除",
|
||||
"cmd.parameters_export": "パラメータをエクスポート",
|
||||
"cmd.parameters_favorite": "お気に入りパラメータ",
|
||||
"cmd.parameters_move": "パラメータを移動",
|
||||
"cmd.parameters_graph": "パラメータ グラフ",
|
||||
"cmd.parameters_import": "パラメータをインポート",
|
||||
"cmd.parameters_list": "パラメータを一覧表示",
|
||||
@@ -161,6 +179,7 @@
|
||||
"cmd.sheet_fold": "折り曲げ",
|
||||
"cmd.sheet_hem": "ヘミング",
|
||||
"cmd.sheet_manage_rules": "板金ルール",
|
||||
"cmd.sheet_perforate": "パンチング",
|
||||
"cmd.sheet_refold": "折り戻し",
|
||||
"cmd.sheet_rules": "板金ルールを一覧表示",
|
||||
"cmd.sheet_unfold": "展開",
|
||||
@@ -184,6 +203,8 @@
|
||||
"cmd.sketch_constraint_fix": "固定/固定解除",
|
||||
"cmd.sketch_constraint_midpoint": "中点",
|
||||
"cmd.sketch_construction": "標準/コンストラクション",
|
||||
"cmd.sketch_copy": "スケッチ要素をコピー",
|
||||
"cmd.sketch_cut": "スケッチ要素を切り取り",
|
||||
"cmd.sketch_delete": "スケッチ要素を削除",
|
||||
"cmd.sketch_dimension_text": "寸法テキストを移動",
|
||||
"cmd.sketch_drag": "スケッチ ジオメトリをドラッグ",
|
||||
@@ -208,6 +229,7 @@
|
||||
"cmd.sketch_move_point": "スケッチ点をドラッグ",
|
||||
"cmd.sketch_offset_break": "オフセット関連付けを解除",
|
||||
"cmd.sketch_options": "スケッチ パレット オプション",
|
||||
"cmd.sketch_paste": "スケッチ要素を貼り付け",
|
||||
"cmd.sketch_point3d": "3D 点",
|
||||
"cmd.sketch_polygon_circumscribed": "外接ポリゴン",
|
||||
"cmd.sketch_polygon_edge": "エッジ ポリゴン",
|
||||
@@ -259,9 +281,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 +299,7 @@
|
||||
"cmd.view_list": "名前付きビューを一覧表示",
|
||||
"cmd.view_rename": "名前付きビューの名前を変更",
|
||||
"cmd.view_save": "新規名前付きビュー",
|
||||
"dialog.computing": "計算中… {seconds} 秒",
|
||||
"dialog.custom_axis": "↖ カスタム軸",
|
||||
"dialog.edit_title": "{title}を編集",
|
||||
"dialog.select": "↖ 選択",
|
||||
@@ -284,6 +309,31 @@
|
||||
"dialog.selection_guidance": "入力欄を選んで変更します。選択済みのオブジェクトをクリックすると解除できます。Ctrl/Cmd を押しながらクリックしても切り替えられます。プレビューを確認してから OK を押してください。",
|
||||
"document.save_changes": "{name} への変更を保存しますか?",
|
||||
"document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。",
|
||||
"home.documentation": "ドキュメント",
|
||||
"home.in_this_browser": "このブラウザ内",
|
||||
"home.missing": "見つかりません",
|
||||
"home.open": "開く…",
|
||||
"home.opened_days_ago": "{count} 日前に開きました",
|
||||
"home.opened_hours_ago": "{count} 時間前に開きました",
|
||||
"home.opened_just_now": "たった今開きました",
|
||||
"home.opened_minutes_ago": "{count} 分前に開きました",
|
||||
"home.opening": "開いています…",
|
||||
"home.recent": "最近使用した項目",
|
||||
"home.report_issue": "問題を報告",
|
||||
"home.samples": "サンプル",
|
||||
"home.whats_new": "新機能",
|
||||
"sample.bracket.about": "2 mm の板金ルールによるベース フランジ、エッジ フランジ、ヘミング",
|
||||
"sample.bracket.name": "板金ブラケット",
|
||||
"sample.enclosure.about": "リップ、ねじボス、レスト、スナップ フィットを備えたシェル化した直方体",
|
||||
"sample.enclosure.name": "プラスチック筐体",
|
||||
"sample.flange.about": "パラメータで制御する円形パターンを持つハブとフランジ",
|
||||
"sample.flange.name": "ボルト締めフランジ",
|
||||
"sample.hinge.about": "2 つのコンポーネントと回転ジョイント、60° まで駆動",
|
||||
"sample.hinge.name": "ヒンジ アセンブリ",
|
||||
"sample.plate.about": "フィレット、穴、穴あきボスを備えたパラメトリック プレート",
|
||||
"sample.plate.name": "サンプル プレート",
|
||||
"sample.vents.about": "矩形パターンに並んだスロット: 行数、列数、ピッチはパラメータ",
|
||||
"sample.vents.name": "通気プレート",
|
||||
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます",
|
||||
"settings.msaa_reset": "{setting}: {requested}× はこのグラフィックスデバイスではサポートされていないため、{reset}× に変更しました(サポート: {supported})。",
|
||||
"settings.msaa_unsupported": "このグラフィックスデバイスではサポートされていません",
|
||||
@@ -301,6 +351,147 @@
|
||||
"ui.active_component": "アクティブなコンポーネント",
|
||||
"ui.agent_cursor": "エージェントカーソル",
|
||||
"ui.agent_cursor_speed": "エージェントカーソル速度",
|
||||
"ui.agent_chat": "エージェントチャット",
|
||||
"ui.agent_chat_command": "エージェントのコマンド",
|
||||
"ui.agent_chat_desktop_only": "エージェントチャットにはデスクトップアプリが必要です。",
|
||||
"ui.agent_chat_side": "エージェントチャットの位置",
|
||||
"ui.agent_chat_left": "左",
|
||||
"ui.agent_chat_right": "右",
|
||||
"ui.agent_chat_turn_limit": "1メッセージあたりの変更数",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。SolidCraft がキーやパスワードを見たり保存したりすることはありません。",
|
||||
"ui.agent_chat_connect": "接続",
|
||||
"ui.agent_chat_stop": "停止",
|
||||
"ui.agent_chat_stop_tip": "エージェントと、それが起動したものをすべて停止します",
|
||||
"ui.agent_chat_close": "閉じる",
|
||||
"ui.agent_chat_minimize": "細いバーに最小化",
|
||||
"ui.agent_chat_dock_left": "左にドッキング",
|
||||
"ui.agent_chat_dock_right": "右にドッキング",
|
||||
"ui.agent_chat_send": "送信",
|
||||
"ui.agent_chat_cancel": "キャンセル",
|
||||
"ui.agent_chat_refresh": "再検索",
|
||||
"ui.agent_chat_pick": "エージェント",
|
||||
"ui.agent_chat_custom_hint": "標準入出力で Agent Client Protocol を話す任意のプログラム。シェルは使いません。",
|
||||
"ui.agent_chat_not_installed": "未インストール",
|
||||
"ui.agent_chat_none_found": "コーディングエージェントが見つかりません。次のいずれかをインストールしてから「再検索」を押してください:",
|
||||
"ui.agent_chat_detecting": "エージェントを検索中…",
|
||||
"ui.agent_chat_signed_in": "サインイン済み",
|
||||
"ui.agent_chat_key_in_env": "キー設定済み",
|
||||
"ui.agent_chat_not_signed_in": "未サインイン",
|
||||
"ui.agent_chat_state_off": "未接続",
|
||||
"ui.agent_chat_state_starting": "起動中…",
|
||||
"ui.agent_chat_state_ready": "準備完了",
|
||||
"ui.agent_chat_state_busy": "作業中…",
|
||||
"ui.agent_chat_state_auth": "サインインが必要",
|
||||
"ui.agent_chat_state_exited": "エージェント停止",
|
||||
"ui.agent_chat_changes_of": "変更 {n}/{limit}",
|
||||
"ui.agent_chat_auth_title": "エージェントにサインイン",
|
||||
"ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。SolidCraft がパスワードやキーを見ることはありません。",
|
||||
"ui.agent_chat_auth_none": "このエージェントはここでのサインイン方法を提示していません。エージェントのドキュメントに従ってサインインしてください。",
|
||||
"ui.agent_chat_auth_terminal": "ターミナルでこれを実行してから、もう一度「接続」を押してください:",
|
||||
"ui.agent_chat_auth_agent": "{name} でサインイン",
|
||||
"ui.agent_chat_auth_env": "環境変数 {vars} を設定して SolidCraft を再起動してください。",
|
||||
"ui.agent_chat_auth_retry": "サインインしました: 再接続",
|
||||
"ui.agent_chat_copy": "コピー",
|
||||
"ui.agent_chat_permission": "エージェントが実行の許可を求めています: {title}",
|
||||
"ui.agent_chat_answered": "回答: {option}",
|
||||
"ui.agent_chat_approval": "エージェントにこの操作を許可しますか?",
|
||||
"ui.agent_chat_allow": "許可",
|
||||
"ui.agent_chat_deny": "拒否",
|
||||
"ui.agent_chat_allowed": "許可しました",
|
||||
"ui.agent_chat_denied": "拒否しました",
|
||||
"ui.agent_chat_changes": "{n} 件の変更(元に戻すは1回)",
|
||||
"ui.agent_chat_undo_changes": "これらの変更を元に戻す",
|
||||
"ui.agent_chat_undone": "(元に戻しました)",
|
||||
"ui.agent_chat_undo_label": "エージェントチャット: {message}",
|
||||
"ui.agent_chat_limit": "エージェントが1メッセージあたりの変更上限 {limit} に達しました。続けさせるには、もう一度メッセージを送ってください。",
|
||||
"ui.agent_chat_refused": "エージェントには許可されていません: {reason}",
|
||||
"ui.agent_chat_wait_turn": "エージェントはまだ前のメッセージを処理中です。待つか「キャンセル」を押してください。",
|
||||
"ui.agent_chat_placeholder": "デザインの変更をエージェントに依頼…",
|
||||
"ui.agent_chat_attach_view": "ビューを添付",
|
||||
"ui.agent_chat_attach_view_tip": "現在のビューの画像をメッセージと一緒に送ります",
|
||||
"ui.agent_chat_no_images": "このエージェントは画像を受け付けません",
|
||||
"ui.agent_chat_view_attached": "ビューを添付済み",
|
||||
"ui.agent_chat_no_cli": "アプリの隣に solidcraft-cli が見つからないため、エージェントはデザインにアクセスできません。",
|
||||
"ui.agent_chat_no_control": "エージェントチャットのエンドポイントが起動しなかったため、エージェントはデザインにアクセスできません。",
|
||||
"ui.agent_chat_exited": "エージェントが停止しました。",
|
||||
"ui.agent_chat_failed_start": "エージェントを起動できませんでした: {error}",
|
||||
"ui.agent_chat_intro": "普段使っているコーディングエージェント、または選んだモデルのエンドポイントとチャットできます。SolidCraft のツールで開いているデザインを操作し、変更はその場で表示され、メッセージごとに1回で元に戻せます。確認なしで許可する範囲は「環境設定 > AI」で設定します。",
|
||||
"ui.agent_chat_privacy": "あなたのアカウントで実行するエージェント以外には、何も送信されません。",
|
||||
"ui.agent_chat_none_found_short": "エージェントが見つかりません",
|
||||
"ui.ai_add": "追加",
|
||||
"ui.ai_add_agent": "エージェントを追加…",
|
||||
"ui.ai_add_agent_title": "エージェントを追加",
|
||||
"ui.ai_add_api": "OpenAI 互換 API",
|
||||
"ui.ai_add_custom": "カスタムコマンド",
|
||||
"ui.ai_added": "追加済み",
|
||||
"ui.ai_add_installed": "インストール済みエージェント",
|
||||
"ui.ai_add_installed_note": "インストール済みエージェントの独自エントリ(例: 別の引数で起動)。インストール済みのエージェントは自動で一覧に表示されます。",
|
||||
"ui.ai_agent_mode_tip": "エージェント自身のモード: エージェント自身のツール(シェル、ファイル編集)はこれに従います。SolidCraft のツールは「環境設定 > AI」の権限に従います。",
|
||||
"ui.ai_agents": "エージェント",
|
||||
"ui.ai_always_allow": "常に許可",
|
||||
"ui.ai_always_allowed": "常に許可されたもの",
|
||||
"ui.ai_always_allowed_none": "まだ常に許可されたものはありません。チャットで「常に許可」と答えたものがここに表示されます。",
|
||||
"ui.ai_always_allow_tip": "今後このエージェントに {tool} を確認なしで許可します",
|
||||
"ui.ai_auto_allowed": "自動許可: {title}({why})",
|
||||
"ui.ai_auto_mode": "権限の設定",
|
||||
"ui.ai_auto_ours": "SolidCraft のツールは SolidCraft 自身が確認します",
|
||||
"ui.ai_auto_remembered": "「常に許可」を選択済み",
|
||||
"ui.ai_bad_url": "http:// または https:// で始まる URL を入力してください",
|
||||
"ui.ai_base_url": "ベース URL",
|
||||
"ui.ai_button": "AI",
|
||||
"ui.ai_copy_suffix": "コピー",
|
||||
"ui.ai_custom_url": "カスタム URL",
|
||||
"ui.ai_default": "既定",
|
||||
"ui.ai_http_busy": "エンドポイントが混雑しているか上限に達しています。後で再試行してください",
|
||||
"ui.ai_http_key": "エンドポイントがキーを拒否しました。「環境設定 > AI」で確認してください",
|
||||
"ui.ai_http_not_found": "エンドポイントまたはモデルが見つかりません。ベース URL とモデル名を確認してください",
|
||||
"ui.ai_key": "API キー",
|
||||
"ui.ai_keychain_error": "システムのキーチェーンが拒否しました: {error}",
|
||||
"ui.ai_key_hint": "任意。システムのキーチェーンに保存",
|
||||
"ui.ai_key_needs_https": "キーは HTTPS 経由、またはこのコンピューター宛てにのみ送信されます。",
|
||||
"ui.ai_key_note": "システムのキーチェーンに保存され、SolidCraft の設定には保存されません。ローカルサーバーの場合は空欄のままにします。",
|
||||
"ui.ai_mode_all": "すべて許可",
|
||||
"ui.ai_mode_all_note": "確認なしで実行します。クラッシュレポートは引き続き対象外で、フォルダー制限も有効です。",
|
||||
"ui.ai_mode_all_warning": "エージェントは確認なしでデザインの保存・上書き・エクスポート・置き換えができます。信頼できるエージェントで、保存済みのデザインに対してのみ使ってください。",
|
||||
"ui.ai_mode_ask": "毎回確認",
|
||||
"ui.ai_mode_ask_note": "デザインへのすべての変更とファイル操作が、あなたの許可を待ちます。",
|
||||
"ui.ai_mode_edits": "モデル編集を自動許可",
|
||||
"ui.ai_mode_edits_note": "モデリングコマンドは確認なしで実行されます。保存・開く・エクスポートなどのファイル操作や破壊的な操作は引き続き確認します。",
|
||||
"ui.ai_mode_global": "上の設定に従う",
|
||||
"ui.ai_model": "モデル",
|
||||
"ui.ai_modes_scope": "これらの設定は SolidCraft のツールに適用されます。エージェント自身のツール(シェル、ファイル編集)は、エージェントにモードがある場合にチャットパネルのヘッダーで選ぶエージェント自身のモードに従います。",
|
||||
"ui.ai_name": "名前",
|
||||
"ui.ai_need_command": "エージェントを起動するコマンドを入力してください",
|
||||
"ui.ai_need_model": "モデル名を入力してください",
|
||||
"ui.ai_no_answer": "エージェントが時間内に応答しませんでした",
|
||||
"ui.ai_no_http": "このビルドは API エンドポイントに接続できません",
|
||||
"ui.ai_no_keychain": "システムのキーチェーンが使えないため、キーを保存できません。キー不要のローカルサーバーは利用できます。",
|
||||
"ui.ai_panel": "パネル",
|
||||
"ui.ai_permissions": "権限",
|
||||
"ui.ai_preset": "プリセット",
|
||||
"ui.ai_remove": "削除",
|
||||
"ui.ai_request_limit": "1メッセージあたり24リクエストで停止しました。続けるには、もう一度メッセージを送ってください",
|
||||
"ui.ai_reset": "リセット",
|
||||
"ui.ai_settings": "AI 設定…",
|
||||
"ui.ai_show_features": "AI 機能を表示",
|
||||
"ui.ai_show_features_note": "エージェントチャット: 普段使っているコーディングエージェント、または選んだモデルのエンドポイントが、開いているデザインを操作するパネルです。オフ: AI ボタン、ショートカット、メニュー項目、パネルは表示されず、検索も起動も行いません。",
|
||||
"ui.ai_switch_tip": "この会話のエージェントを切り替え",
|
||||
"ui.ai_test": "テスト",
|
||||
"ui.ai_test_acp_ok": "応答あり: {agent}",
|
||||
"ui.ai_test_acp_tip": "エージェントを短時間起動してセッションを要求し、終了します",
|
||||
"ui.ai_test_api_tip": "エンドポイントにモデル一覧を問い合わせます",
|
||||
"ui.ai_testing": "テスト中…",
|
||||
"ui.ai_test_no_model": "接続成功: {count} 個のモデル({model} はありません)",
|
||||
"ui.ai_test_ok": "接続成功: {count} 個のモデル",
|
||||
"ui.ai_toolbar_button": "ツールバーにボタンを表示",
|
||||
"ui.ai_toolbar_button_note": "ツールバー右端の AI ボタン。Ctrl+Shift+A と表示メニューからも引き続きパネルを開けます。",
|
||||
"ui.ai_tool_done": "完了",
|
||||
"ui.ai_tool_failed": "失敗",
|
||||
"ui.ai_tool_running": "実行中",
|
||||
"ui.ai_tool_unfold": "クリックして結果を表示",
|
||||
"ui.ai_tool_waiting": "待機中",
|
||||
"ui.ai_undo_tip": "このメッセージでエージェントが行った変更を、1回でまとめて元に戻します",
|
||||
"AI": "AI",
|
||||
"ui.all": "すべて",
|
||||
"ui.analysis": "解析",
|
||||
"ui.angle": "角度",
|
||||
@@ -309,6 +500,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": "アセンブリ",
|
||||
@@ -338,10 +539,47 @@
|
||||
"ui.click_selects_whole_bodies": "クリックでボディ全体を選択",
|
||||
"ui.click_then_press_key": "クリックした後、割り当てるキーを押してください",
|
||||
"ui.close": "閉じる",
|
||||
"ui.material_review.title": "物理材料データを確認",
|
||||
"ui.material_review.scope": "候補のみ:割り当てや保存は行いません。数値検証は工学的認証ではありません。",
|
||||
"ui.material_review.input": "スキーマ1の材料カードを貼り付け(JSON)",
|
||||
"ui.material_review.review": "カードを確認",
|
||||
"ui.material_review.limit": "カードが256 KiBの入力上限を超えています。",
|
||||
"ui.material_review.unknown": "不明",
|
||||
"ui.material_review.properties": "特性",
|
||||
"ui.material_review.conditions": "記載された条件",
|
||||
"ui.material_review.source": "出典",
|
||||
"ui.material_review.axes": "材料ローカル軸。ボディの方向は割り当てません。",
|
||||
"ui.material_review.derived": "導出された等方性弾性率",
|
||||
"ui.material_review.claims": "出典とライセンスの申告は未確認です。欠落値は不明のままです。",
|
||||
"ui.material_review.density": "密度",
|
||||
"ui.material_review.elasticity": "弾性",
|
||||
"ui.material_review.tensile_strength": "引張強度",
|
||||
"ui.material_review.compressive_strength": "圧縮強度",
|
||||
"ui.material_review.yield_strength": "降伏強度",
|
||||
"ui.material_review.shear_strength": "せん断強度",
|
||||
"ui.material_review.thermal_conductivity": "熱伝導率",
|
||||
"ui.material_review.thermal_expansion": "熱膨張",
|
||||
"ui.material_review.specific_heat": "比熱",
|
||||
"ui.material_review.moisture_unit": "kg 水 / kg 乾燥材料",
|
||||
"ui.coil": "コイル",
|
||||
"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 +791,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 +809,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 +987,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 +1177,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 +1211,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 +1239,143 @@
|
||||
"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} が利用できます",
|
||||
"install.menu": "アプリをインストール…",
|
||||
"install.button_tip": "SolidCraft をアプリとしてインストールします。専用のウィンドウで開き、オフラインでも使えます",
|
||||
"install.title": "SolidCraft をインストール",
|
||||
"install.steps_intro": "Safari では、Web アプリを Safari 自身のメニューからインストールします:",
|
||||
"install.home_screen_1": "「共有」をタップします(ツールバーにない場合は … メニューの中にあります)。",
|
||||
"install.home_screen_2": "「ホーム画面に追加」を選びます。",
|
||||
"install.home_screen_3": "「追加」をタップします。",
|
||||
"install.home_screen_after": "SolidCraft はホーム画面のアイコンから Safari のツールバーなしで開き、オフラインでも使えます。",
|
||||
"install.dock_1": "「ファイル」›「Dockに追加」を選びます。",
|
||||
"install.dock_2": "「追加」をクリックします。",
|
||||
"install.dock_after": "SolidCraft は Dock から専用のウィンドウで開き、オフラインでも使えます。",
|
||||
"install.done": "完了",
|
||||
"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_too_large": "画像の上限を超えています:一辺 4096 ピクセル、合計 4,194,304 ピクセル。",
|
||||
"render.image_use_largest": "この縦横比での最大サイズを使用:",
|
||||
"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": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。",
|
||||
"Circumference": "円周",
|
||||
"Arc length": "円弧の長さ",
|
||||
"Slot width": "長穴の幅",
|
||||
"Centre to centre": "中心間",
|
||||
"Overall length": "全長",
|
||||
"Min": "最小",
|
||||
"Max": "最大"
|
||||
}
|
||||
|
||||
+426
-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": "↖ 선택",
|
||||
@@ -14,6 +26,31 @@
|
||||
"dialog.selection_guidance": "변경할 입력을 선택하세요. 선택한 객체를 클릭하면 선택이 해제되며, Ctrl/Cmd를 누른 채 클릭해도 전환됩니다. 미리 보기를 확인한 다음 확인을 누르세요.",
|
||||
"document.save_changes": "{name}의 변경 사항을 저장하시겠습니까?",
|
||||
"document.unsaved_changes": "{name}에 저장되지 않은 변경 사항이 있습니다. 저장하지 않으면 변경 사항이 사라집니다.",
|
||||
"home.documentation": "문서",
|
||||
"home.in_this_browser": "이 브라우저에 저장됨",
|
||||
"home.missing": "찾을 수 없음",
|
||||
"home.open": "열기…",
|
||||
"home.opened_days_ago": "{count}일 전에 열림",
|
||||
"home.opened_hours_ago": "{count}시간 전에 열림",
|
||||
"home.opened_just_now": "방금 열림",
|
||||
"home.opened_minutes_ago": "{count}분 전에 열림",
|
||||
"home.opening": "여는 중…",
|
||||
"home.recent": "최근 항목",
|
||||
"home.report_issue": "문제 신고",
|
||||
"home.samples": "샘플",
|
||||
"home.whats_new": "새로운 기능",
|
||||
"sample.bracket.about": "2 mm 판금 규칙을 적용한 베이스 플랜지, 에지 플랜지, 헴",
|
||||
"sample.bracket.name": "판금 브래킷",
|
||||
"sample.enclosure.about": "리프, 나사 보스, 레스트, 스냅핏이 있는 쉘 처리된 박스",
|
||||
"sample.enclosure.name": "플라스틱 인클로저",
|
||||
"sample.flange.about": "매개변수로 제어되는 원형 패턴이 있는 허브와 플랜지",
|
||||
"sample.flange.name": "볼트 플랜지",
|
||||
"sample.hinge.about": "두 구성 요소와 회전 관절, 60°까지 구동",
|
||||
"sample.hinge.name": "힌지 어셈블리",
|
||||
"sample.plate.about": "필렛, 구멍, 보어 가공된 보스가 있는 매개변수 기반 플레이트",
|
||||
"sample.plate.name": "샘플 플레이트",
|
||||
"sample.vents.about": "직사각형 패턴의 슬롯: 행, 열, 피치가 매개변수",
|
||||
"sample.vents.name": "통풍 플레이트",
|
||||
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다",
|
||||
"settings.msaa_reset": "{setting}: {requested}×는 이 그래픽 장치에서 지원되지 않아 {reset}×로 변경했습니다(지원: {supported}).",
|
||||
"settings.msaa_unsupported": "이 그래픽 장치에서 지원되지 않음",
|
||||
@@ -29,6 +66,147 @@
|
||||
"ui.active_component": "활성 컴포넌트",
|
||||
"ui.agent_cursor": "에이전트 커서",
|
||||
"ui.agent_cursor_speed": "에이전트 커서 속도",
|
||||
"ui.agent_chat": "에이전트 채팅",
|
||||
"ui.agent_chat_command": "에이전트 명령",
|
||||
"ui.agent_chat_desktop_only": "에이전트 채팅은 데스크톱 앱이 필요합니다.",
|
||||
"ui.agent_chat_side": "에이전트 채팅 위치",
|
||||
"ui.agent_chat_left": "왼쪽",
|
||||
"ui.agent_chat_right": "오른쪽",
|
||||
"ui.agent_chat_turn_limit": "메시지당 변경 수",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft는 아무것도 어디로도 보내지 않습니다. 실행한 에이전트가 사용자의 계정으로 자신의 서비스와 통신합니다. SolidCraft는 키나 비밀번호를 보거나 저장하지 않습니다.",
|
||||
"ui.agent_chat_connect": "연결",
|
||||
"ui.agent_chat_stop": "중지",
|
||||
"ui.agent_chat_stop_tip": "에이전트와 에이전트가 시작한 모든 것을 중지",
|
||||
"ui.agent_chat_close": "닫기",
|
||||
"ui.agent_chat_minimize": "얇은 막대로 최소화",
|
||||
"ui.agent_chat_dock_left": "왼쪽에 고정",
|
||||
"ui.agent_chat_dock_right": "오른쪽에 고정",
|
||||
"ui.agent_chat_send": "보내기",
|
||||
"ui.agent_chat_cancel": "취소",
|
||||
"ui.agent_chat_refresh": "새로 고침",
|
||||
"ui.agent_chat_pick": "에이전트",
|
||||
"ui.agent_chat_custom_hint": "표준 입출력으로 Agent Client Protocol을 사용하는 모든 프로그램. 셸 없이 실행됩니다.",
|
||||
"ui.agent_chat_not_installed": "설치되지 않음",
|
||||
"ui.agent_chat_none_found": "코딩 에이전트를 찾지 못했습니다. 다음 중 하나를 설치한 뒤 새로 고침을 누르세요:",
|
||||
"ui.agent_chat_detecting": "에이전트 찾는 중…",
|
||||
"ui.agent_chat_signed_in": "로그인됨",
|
||||
"ui.agent_chat_key_in_env": "키 설정됨",
|
||||
"ui.agent_chat_not_signed_in": "로그인 안 됨",
|
||||
"ui.agent_chat_state_off": "연결 안 됨",
|
||||
"ui.agent_chat_state_starting": "시작하는 중…",
|
||||
"ui.agent_chat_state_ready": "준비됨",
|
||||
"ui.agent_chat_state_busy": "작업 중…",
|
||||
"ui.agent_chat_state_auth": "로그인 필요",
|
||||
"ui.agent_chat_state_exited": "에이전트 중지됨",
|
||||
"ui.agent_chat_changes_of": "변경 {n}/{limit}",
|
||||
"ui.agent_chat_auth_title": "에이전트에 로그인",
|
||||
"ui.agent_chat_auth_needed": "에이전트가 로그인을 요청합니다. 로그인은 에이전트에서 직접 하며, SolidCraft는 비밀번호나 키를 보지 않습니다.",
|
||||
"ui.agent_chat_auth_none": "이 에이전트는 여기서 로그인 방법을 제공하지 않습니다. 에이전트 문서에 안내된 방법으로 로그인하세요.",
|
||||
"ui.agent_chat_auth_terminal": "터미널에서 이것을 실행한 뒤 다시 연결을 누르세요:",
|
||||
"ui.agent_chat_auth_agent": "{name}(으)로 로그인",
|
||||
"ui.agent_chat_auth_env": "환경 변수 {vars}을(를) 설정하고 SolidCraft를 다시 시작하세요.",
|
||||
"ui.agent_chat_auth_retry": "로그인했습니다: 다시 연결",
|
||||
"ui.agent_chat_copy": "복사",
|
||||
"ui.agent_chat_permission": "에이전트가 실행 허락을 요청합니다: {title}",
|
||||
"ui.agent_chat_answered": "응답: {option}",
|
||||
"ui.agent_chat_approval": "에이전트가 이 작업을 하도록 허용할까요?",
|
||||
"ui.agent_chat_allow": "허용",
|
||||
"ui.agent_chat_deny": "거부",
|
||||
"ui.agent_chat_allowed": "허용함",
|
||||
"ui.agent_chat_denied": "거부함",
|
||||
"ui.agent_chat_changes": "변경 {n}개, 실행 취소 한 번",
|
||||
"ui.agent_chat_undo_changes": "이 변경 사항 실행 취소",
|
||||
"ui.agent_chat_undone": "(취소됨)",
|
||||
"ui.agent_chat_undo_label": "에이전트 채팅: {message}",
|
||||
"ui.agent_chat_limit": "에이전트가 메시지당 변경 한도 {limit}에 도달했습니다. 계속하게 하려면 메시지를 하나 더 보내세요.",
|
||||
"ui.agent_chat_refused": "에이전트에게 허용되지 않음: {reason}",
|
||||
"ui.agent_chat_wait_turn": "에이전트가 아직 이전 메시지를 처리 중입니다. 기다리거나 취소를 누르세요.",
|
||||
"ui.agent_chat_placeholder": "에이전트에게 디자인 변경을 요청하세요…",
|
||||
"ui.agent_chat_attach_view": "뷰 첨부",
|
||||
"ui.agent_chat_attach_view_tip": "현재 뷰의 이미지를 메시지와 함께 보냅니다",
|
||||
"ui.agent_chat_no_images": "이 에이전트는 이미지를 받지 않습니다",
|
||||
"ui.agent_chat_view_attached": "뷰 첨부됨",
|
||||
"ui.agent_chat_no_cli": "앱 옆에서 solidcraft-cli를 찾지 못해 에이전트가 디자인에 접근할 수 없습니다.",
|
||||
"ui.agent_chat_no_control": "에이전트 채팅 엔드포인트가 시작되지 않아 에이전트가 디자인에 접근할 수 없습니다.",
|
||||
"ui.agent_chat_exited": "에이전트가 중지되었습니다.",
|
||||
"ui.agent_chat_failed_start": "에이전트를 시작할 수 없습니다: {error}",
|
||||
"ui.agent_chat_intro": "이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트와 채팅하세요. SolidCraft 도구로 열린 디자인을 다루며, 변경 사항은 바로 보이고 메시지마다 한 번에 실행 취소할 수 있습니다. 묻지 않고 할 수 있는 일은 환경 설정 > AI에서 정합니다.",
|
||||
"ui.agent_chat_privacy": "사용자의 계정으로 실행하는 에이전트 외에는 아무 데도 전송되지 않습니다.",
|
||||
"ui.agent_chat_none_found_short": "에이전트를 찾지 못함",
|
||||
"ui.ai_add": "추가",
|
||||
"ui.ai_add_agent": "에이전트 추가…",
|
||||
"ui.ai_add_agent_title": "에이전트 추가",
|
||||
"ui.ai_add_api": "OpenAI 호환 API",
|
||||
"ui.ai_add_custom": "사용자 지정 명령",
|
||||
"ui.ai_added": "추가됨",
|
||||
"ui.ai_add_installed": "설치된 에이전트",
|
||||
"ui.ai_add_installed_note": "설치된 에이전트에 대한 별도 항목(예: 다른 인수로 실행). 설치된 에이전트는 자동으로 표시됩니다.",
|
||||
"ui.ai_agent_mode_tip": "에이전트 자체 모드: 에이전트 자체 도구(셸, 파일 편집)는 이 모드를 따릅니다. SolidCraft 도구는 환경 설정 > AI의 권한을 따릅니다.",
|
||||
"ui.ai_agents": "에이전트",
|
||||
"ui.ai_always_allow": "항상 허용",
|
||||
"ui.ai_always_allowed": "항상 허용됨",
|
||||
"ui.ai_always_allowed_none": "아직 항상 허용된 것이 없습니다. 채팅에서 \"항상 허용\"으로 답한 항목이 여기에 표시됩니다.",
|
||||
"ui.ai_always_allow_tip": "이 에이전트에게 앞으로 {tool}을(를) 묻지 않고 허용",
|
||||
"ui.ai_auto_allowed": "자동 허용: {title} ({why})",
|
||||
"ui.ai_auto_mode": "권한 설정",
|
||||
"ui.ai_auto_ours": "SolidCraft가 자체 도구를 직접 확인합니다",
|
||||
"ui.ai_auto_remembered": "\"항상 허용\"을 선택함",
|
||||
"ui.ai_bad_url": "http:// 또는 https://로 시작하는 URL을 입력하세요",
|
||||
"ui.ai_base_url": "기본 URL",
|
||||
"ui.ai_button": "AI",
|
||||
"ui.ai_copy_suffix": "복사본",
|
||||
"ui.ai_custom_url": "사용자 지정 URL",
|
||||
"ui.ai_default": "기본값",
|
||||
"ui.ai_http_busy": "엔드포인트가 바쁘거나 한도에 도달했습니다. 나중에 다시 시도하세요",
|
||||
"ui.ai_http_key": "엔드포인트가 키를 거부했습니다. 환경 설정 > AI에서 확인하세요",
|
||||
"ui.ai_http_not_found": "엔드포인트나 모델을 찾을 수 없습니다. 기본 URL과 모델 이름을 확인하세요",
|
||||
"ui.ai_key": "API 키",
|
||||
"ui.ai_keychain_error": "시스템 키체인이 거부했습니다: {error}",
|
||||
"ui.ai_key_hint": "선택 사항, 시스템 키체인에 저장",
|
||||
"ui.ai_key_needs_https": "키는 HTTPS를 통해서만, 또는 이 컴퓨터로만 전송됩니다.",
|
||||
"ui.ai_key_note": "SolidCraft 설정이 아닌 시스템 키체인에 저장됩니다. 로컬 서버라면 비워 두세요.",
|
||||
"ui.ai_mode_all": "모두 허용",
|
||||
"ui.ai_mode_all_note": "묻지 않고 실행합니다. 충돌 보고서는 계속 제외되고 폴더 제한도 그대로 적용됩니다.",
|
||||
"ui.ai_mode_all_warning": "에이전트가 묻지 않고 디자인을 저장, 덮어쓰기, 내보내기, 교체할 수 있습니다. 신뢰하는 에이전트와 저장된 디자인에서만 사용하세요.",
|
||||
"ui.ai_mode_ask": "매번 묻기",
|
||||
"ui.ai_mode_ask_note": "디자인의 모든 변경과 파일 작업이 사용자의 허용을 기다립니다.",
|
||||
"ui.ai_mode_edits": "모델 편집 자동 허용",
|
||||
"ui.ai_mode_edits_note": "모델링 명령은 묻지 않고 실행되며, 저장·열기·내보내기 등 파일 작업이나 파괴적인 작업은 계속 묻습니다.",
|
||||
"ui.ai_mode_global": "위 설정 따름",
|
||||
"ui.ai_model": "모델",
|
||||
"ui.ai_modes_scope": "이 설정은 SolidCraft 도구에 적용됩니다. 에이전트 자체 도구(셸, 파일 편집)는 에이전트에 모드가 있을 때 채팅 패널 머리글에서 고르는 에이전트 자체 모드를 따릅니다.",
|
||||
"ui.ai_name": "이름",
|
||||
"ui.ai_need_command": "에이전트를 시작하는 명령을 입력하세요",
|
||||
"ui.ai_need_model": "모델 이름을 입력하세요",
|
||||
"ui.ai_no_answer": "에이전트가 제시간에 응답하지 않았습니다",
|
||||
"ui.ai_no_http": "이 빌드는 API 엔드포인트에 연결할 수 없습니다",
|
||||
"ui.ai_no_keychain": "시스템 키체인을 사용할 수 없어 키를 저장할 수 없습니다. 키가 필요 없는 로컬 서버는 사용할 수 있습니다.",
|
||||
"ui.ai_panel": "패널",
|
||||
"ui.ai_permissions": "권한",
|
||||
"ui.ai_preset": "프리셋",
|
||||
"ui.ai_remove": "제거",
|
||||
"ui.ai_request_limit": "메시지 하나에 24번 요청 후 멈췄습니다. 계속하려면 메시지를 하나 더 보내세요",
|
||||
"ui.ai_reset": "재설정",
|
||||
"ui.ai_settings": "AI 설정…",
|
||||
"ui.ai_show_features": "AI 기능 표시",
|
||||
"ui.ai_show_features_note": "에이전트 채팅: 이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트가 열린 디자인을 다루는 패널입니다. 끄면 AI 버튼, 단축키, 메뉴 항목, 패널이 없고 아무것도 찾거나 시작하지 않습니다.",
|
||||
"ui.ai_switch_tip": "이 대화의 에이전트 변경",
|
||||
"ui.ai_test": "테스트",
|
||||
"ui.ai_test_acp_ok": "응답: {agent}",
|
||||
"ui.ai_test_acp_tip": "에이전트를 잠시 시작해 세션을 요청한 뒤 종료합니다",
|
||||
"ui.ai_test_api_tip": "엔드포인트에 모델 목록을 요청합니다",
|
||||
"ui.ai_testing": "테스트 중…",
|
||||
"ui.ai_test_no_model": "연결됨: 모델 {count}개, {model} 없음",
|
||||
"ui.ai_test_ok": "연결됨: 모델 {count}개",
|
||||
"ui.ai_toolbar_button": "도구 모음에 버튼 표시",
|
||||
"ui.ai_toolbar_button_note": "도구 모음 오른쪽 끝의 AI 버튼. Ctrl+Shift+A와 보기 메뉴로도 패널을 열 수 있습니다.",
|
||||
"ui.ai_tool_done": "완료",
|
||||
"ui.ai_tool_failed": "실패",
|
||||
"ui.ai_tool_running": "실행 중",
|
||||
"ui.ai_tool_unfold": "클릭하면 결과를 표시",
|
||||
"ui.ai_tool_waiting": "대기 중",
|
||||
"ui.ai_undo_tip": "이 메시지에서 에이전트가 바꾼 모든 것을 한 번에 실행 취소",
|
||||
"AI": "AI",
|
||||
"ui.all": "모두",
|
||||
"ui.analysis": "분석",
|
||||
"ui.angle": "각도",
|
||||
@@ -37,6 +215,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": "조립",
|
||||
@@ -63,10 +251,47 @@
|
||||
"ui.clear_recent": "최근 항목 지우기",
|
||||
"ui.clear_the_selection": "선택 해제",
|
||||
"ui.close": "닫기",
|
||||
"ui.material_review.title": "물리적 재료 데이터 검토",
|
||||
"ui.material_review.scope": "후보만 검토합니다. 지정하거나 저장하지 않습니다. 수치 검증은 공학적 인증이 아닙니다.",
|
||||
"ui.material_review.input": "스키마 1 재료 카드 붙여넣기 (JSON)",
|
||||
"ui.material_review.review": "카드 검토",
|
||||
"ui.material_review.limit": "카드가 256 KiB 입력 한도를 초과합니다.",
|
||||
"ui.material_review.unknown": "알 수 없음",
|
||||
"ui.material_review.properties": "물성",
|
||||
"ui.material_review.conditions": "명시된 조건",
|
||||
"ui.material_review.source": "출처",
|
||||
"ui.material_review.axes": "재료 로컬 축입니다. 바디 방향을 지정하지 않습니다.",
|
||||
"ui.material_review.derived": "유도된 등방성 탄성 계수",
|
||||
"ui.material_review.claims": "출처와 라이선스는 검증되지 않은 주장입니다. 누락된 값은 알 수 없는 상태로 유지됩니다.",
|
||||
"ui.material_review.density": "밀도",
|
||||
"ui.material_review.elasticity": "탄성",
|
||||
"ui.material_review.tensile_strength": "인장 강도",
|
||||
"ui.material_review.compressive_strength": "압축 강도",
|
||||
"ui.material_review.yield_strength": "항복 강도",
|
||||
"ui.material_review.shear_strength": "전단 강도",
|
||||
"ui.material_review.thermal_conductivity": "열전도율",
|
||||
"ui.material_review.thermal_expansion": "열팽창",
|
||||
"ui.material_review.specific_heat": "비열",
|
||||
"ui.material_review.moisture_unit": "kg 물 / kg 건조 재료",
|
||||
"ui.coil": "코일",
|
||||
"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 +485,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 +500,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 +614,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 +648,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": "외관 목록",
|
||||
@@ -404,6 +671,7 @@
|
||||
"cmd.component_capture_position": "위치 캡처",
|
||||
"cmd.component_create": "새 구성 요소",
|
||||
"cmd.component_delete": "구성 요소 삭제",
|
||||
"cmd.component_unlock": "내용 잠금 해제",
|
||||
"cmd.component_from_bodies": "바디로 구성 요소 만들기",
|
||||
"cmd.component_insert_design": "설계 삽입",
|
||||
"cmd.component_links": "링크 목록",
|
||||
@@ -456,6 +724,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,8 +740,10 @@
|
||||
"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_compare": "바디 비교",
|
||||
"cmd.inspect_curvature_comb": "곡률빗 분석",
|
||||
"cmd.inspect_curvature_map": "곡률맵 분석",
|
||||
"cmd.inspect_draft": "블록각 분석",
|
||||
@@ -495,6 +768,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 +790,7 @@
|
||||
"cmd.parameters_delete": "매개 변수 삭제",
|
||||
"cmd.parameters_export": "매개 변수 내보내기",
|
||||
"cmd.parameters_favorite": "즐겨찾는 매개 변수",
|
||||
"cmd.parameters_move": "매개 변수 이동",
|
||||
"cmd.parameters_graph": "매개 변수 그래프",
|
||||
"cmd.parameters_import": "매개 변수 가져오기",
|
||||
"cmd.parameters_list": "매개 변수 목록",
|
||||
@@ -540,6 +816,7 @@
|
||||
"cmd.sheet_fold": "접기",
|
||||
"cmd.sheet_hem": "헴",
|
||||
"cmd.sheet_manage_rules": "판금 규칙",
|
||||
"cmd.sheet_perforate": "타공",
|
||||
"cmd.sheet_refold": "다시 접기",
|
||||
"cmd.sheet_rules": "판금 규칙 목록",
|
||||
"cmd.sheet_unfold": "펼치기",
|
||||
@@ -563,6 +840,8 @@
|
||||
"cmd.sketch_constraint_fix": "고정/고정 해제",
|
||||
"cmd.sketch_constraint_midpoint": "중간점",
|
||||
"cmd.sketch_construction": "일반/구성선",
|
||||
"cmd.sketch_copy": "스케치 요소 복사",
|
||||
"cmd.sketch_cut": "스케치 요소 잘라내기",
|
||||
"cmd.sketch_delete": "스케치 개체 삭제",
|
||||
"cmd.sketch_dimension_text": "치수 텍스트 이동",
|
||||
"cmd.sketch_drag": "스케치 기하 드래그",
|
||||
@@ -587,6 +866,7 @@
|
||||
"cmd.sketch_move_point": "스케치 점 드래그",
|
||||
"cmd.sketch_offset_break": "오프셋 연결 끊기",
|
||||
"cmd.sketch_options": "스케치 팔레트 옵션",
|
||||
"cmd.sketch_paste": "스케치 요소 붙여넣기",
|
||||
"cmd.sketch_point3d": "3D 점",
|
||||
"cmd.sketch_polygon_circumscribed": "외접 다각형",
|
||||
"cmd.sketch_polygon_edge": "변 다각형",
|
||||
@@ -637,9 +917,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 +940,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 +982,145 @@
|
||||
"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} 사용 가능",
|
||||
"install.menu": "앱 설치…",
|
||||
"install.button_tip": "SolidCraft를 앱으로 설치합니다. 별도 창에서 열리며 오프라인에서도 작동합니다",
|
||||
"install.title": "SolidCraft 설치",
|
||||
"install.steps_intro": "Safari는 자체 메뉴에서 웹 앱을 설치합니다:",
|
||||
"install.home_screen_1": "공유를 탭합니다(도구 막대에 없으면 … 메뉴 안에 있습니다).",
|
||||
"install.home_screen_2": "홈 화면에 추가를 선택합니다.",
|
||||
"install.home_screen_3": "추가를 탭합니다.",
|
||||
"install.home_screen_after": "이후 SolidCraft는 홈 화면의 아이콘에서 Safari 도구 막대 없이 열리며 오프라인에서도 작동합니다.",
|
||||
"install.dock_1": "파일 › Dock에 추가를 선택합니다.",
|
||||
"install.dock_2": "추가를 클릭합니다.",
|
||||
"install.dock_after": "이후 SolidCraft는 Dock에서 별도 창으로 열리며 오프라인에서도 작동합니다.",
|
||||
"install.done": "완료",
|
||||
"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_too_large": "이미지 한도를 초과했습니다: 한 변 4096픽셀, 전체 4,194,304픽셀.",
|
||||
"render.image_use_largest": "이 비율에서 가장 큰 크기 사용:",
|
||||
"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": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다.",
|
||||
"Circumference": "원주",
|
||||
"Arc length": "호 길이",
|
||||
"Slot width": "슬롯 폭",
|
||||
"Centre to centre": "중심 간",
|
||||
"Overall length": "전체 길이",
|
||||
"Min": "최소",
|
||||
"Max": "최대",
|
||||
"Length": "길이",
|
||||
"Area": "면적"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+421
-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})",
|
||||
@@ -25,6 +36,7 @@
|
||||
"cmd.component_capture_position": "捕获位置",
|
||||
"cmd.component_create": "新建组件",
|
||||
"cmd.component_delete": "删除组件",
|
||||
"cmd.component_unlock": "解锁内容",
|
||||
"cmd.component_from_bodies": "由实体创建组件",
|
||||
"cmd.component_insert_design": "插入设计",
|
||||
"cmd.component_links": "列出链接",
|
||||
@@ -77,6 +89,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,8 +105,10 @@
|
||||
"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_compare": "比较实体",
|
||||
"cmd.inspect_curvature_comb": "曲率梳分析",
|
||||
"cmd.inspect_curvature_map": "曲率图分析",
|
||||
"cmd.inspect_draft": "拔模分析",
|
||||
@@ -136,6 +153,7 @@
|
||||
"cmd.parameters_delete": "删除参数",
|
||||
"cmd.parameters_export": "导出参数",
|
||||
"cmd.parameters_favorite": "收藏参数",
|
||||
"cmd.parameters_move": "移动参数",
|
||||
"cmd.parameters_graph": "参数关系图",
|
||||
"cmd.parameters_import": "导入参数",
|
||||
"cmd.parameters_list": "列出参数",
|
||||
@@ -161,6 +179,7 @@
|
||||
"cmd.sheet_fold": "折叠",
|
||||
"cmd.sheet_hem": "卷边",
|
||||
"cmd.sheet_manage_rules": "钣金规则",
|
||||
"cmd.sheet_perforate": "冲孔",
|
||||
"cmd.sheet_refold": "重新折弯",
|
||||
"cmd.sheet_rules": "列出钣金规则",
|
||||
"cmd.sheet_unfold": "展开",
|
||||
@@ -184,6 +203,8 @@
|
||||
"cmd.sketch_constraint_fix": "固定/解除固定",
|
||||
"cmd.sketch_constraint_midpoint": "中点",
|
||||
"cmd.sketch_construction": "普通/构造",
|
||||
"cmd.sketch_copy": "复制草图图元",
|
||||
"cmd.sketch_cut": "剪切草图图元",
|
||||
"cmd.sketch_delete": "删除草图图元",
|
||||
"cmd.sketch_dimension_text": "移动尺寸文字",
|
||||
"cmd.sketch_drag": "拖动草图几何",
|
||||
@@ -208,6 +229,7 @@
|
||||
"cmd.sketch_move_point": "拖动草图点",
|
||||
"cmd.sketch_offset_break": "断开偏移关联",
|
||||
"cmd.sketch_options": "草图选项板选项",
|
||||
"cmd.sketch_paste": "粘贴草图图元",
|
||||
"cmd.sketch_point3d": "三维点",
|
||||
"cmd.sketch_polygon_circumscribed": "外切多边形",
|
||||
"cmd.sketch_polygon_edge": "边长多边形",
|
||||
@@ -259,9 +281,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 +299,7 @@
|
||||
"cmd.view_list": "列出命名视图",
|
||||
"cmd.view_rename": "重命名命名视图",
|
||||
"cmd.view_save": "新建命名视图",
|
||||
"dialog.computing": "正在计算… {seconds} 秒",
|
||||
"dialog.custom_axis": "↖ 自定义轴",
|
||||
"dialog.edit_title": "编辑 {title}",
|
||||
"dialog.select": "↖ 选择",
|
||||
@@ -284,6 +309,31 @@
|
||||
"dialog.selection_guidance": "选择输入项进行修改。点击已选对象可取消选择;按住 Ctrl/Cmd 点击也可切换选择。检查预览后,再按“确定”。",
|
||||
"document.save_changes": "保存对 {name} 的更改?",
|
||||
"document.unsaved_changes": "{name} 有未保存的更改。如果不保存,将丢失这些更改。",
|
||||
"home.documentation": "文档",
|
||||
"home.in_this_browser": "在此浏览器中",
|
||||
"home.missing": "文件不存在",
|
||||
"home.open": "打开…",
|
||||
"home.opened_days_ago": "{count} 天前打开",
|
||||
"home.opened_hours_ago": "{count} 小时前打开",
|
||||
"home.opened_just_now": "刚刚打开",
|
||||
"home.opened_minutes_ago": "{count} 分钟前打开",
|
||||
"home.opening": "正在打开…",
|
||||
"home.recent": "最近使用",
|
||||
"home.report_issue": "报告问题",
|
||||
"home.samples": "示例",
|
||||
"home.whats_new": "新功能",
|
||||
"sample.bracket.about": "基于 2 mm 钣金规则的基础法兰、边线法兰和卷边",
|
||||
"sample.bracket.name": "钣金支架",
|
||||
"sample.enclosure.about": "带凸缘、螺钉凸台、垫块和卡扣的抽壳长方体",
|
||||
"sample.enclosure.name": "塑料外壳",
|
||||
"sample.flange.about": "带有由参数驱动的圆形阵列的轮毂和法兰",
|
||||
"sample.flange.name": "螺栓法兰",
|
||||
"sample.hinge.about": "两个组件和一个旋转接头,驱动到 60°",
|
||||
"sample.hinge.name": "铰链装配体",
|
||||
"sample.plate.about": "带圆角、孔和镗孔凸台的参数化板",
|
||||
"sample.plate.name": "示例板",
|
||||
"sample.vents.about": "按矩形阵列排列的槽:行数、列数和间距均为参数",
|
||||
"sample.vents.name": "通风板",
|
||||
"settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效",
|
||||
"settings.msaa_reset": "{setting}: {requested}× 不受此图形设备支持;已重置为 {reset}× (支持: {supported})。",
|
||||
"settings.msaa_unsupported": "此图形设备不支持",
|
||||
@@ -301,6 +351,147 @@
|
||||
"ui.active_component": "活动组件",
|
||||
"ui.agent_cursor": "代理光标",
|
||||
"ui.agent_cursor_speed": "代理光标速度",
|
||||
"ui.agent_chat": "智能体聊天",
|
||||
"ui.agent_chat_command": "智能体命令",
|
||||
"ui.agent_chat_desktop_only": "智能体聊天需要桌面应用。",
|
||||
"ui.agent_chat_side": "智能体聊天位置",
|
||||
"ui.agent_chat_left": "左侧",
|
||||
"ui.agent_chat_right": "右侧",
|
||||
"ui.agent_chat_turn_limit": "每条消息的更改数",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。SolidCraft 从不查看或保存任何密钥或密码。",
|
||||
"ui.agent_chat_connect": "连接",
|
||||
"ui.agent_chat_stop": "停止",
|
||||
"ui.agent_chat_stop_tip": "停止智能体及其启动的所有进程",
|
||||
"ui.agent_chat_close": "关闭",
|
||||
"ui.agent_chat_minimize": "最小化为细条",
|
||||
"ui.agent_chat_dock_left": "停靠在左侧",
|
||||
"ui.agent_chat_dock_right": "停靠在右侧",
|
||||
"ui.agent_chat_send": "发送",
|
||||
"ui.agent_chat_cancel": "取消",
|
||||
"ui.agent_chat_refresh": "刷新",
|
||||
"ui.agent_chat_pick": "智能体",
|
||||
"ui.agent_chat_custom_hint": "任何通过标准输入输出使用 Agent Client Protocol 的程序。不经过 shell 运行。",
|
||||
"ui.agent_chat_not_installed": "未安装",
|
||||
"ui.agent_chat_none_found": "未找到编程智能体。请安装以下之一,然后点击刷新:",
|
||||
"ui.agent_chat_detecting": "正在查找智能体…",
|
||||
"ui.agent_chat_signed_in": "已登录",
|
||||
"ui.agent_chat_key_in_env": "已设置密钥",
|
||||
"ui.agent_chat_not_signed_in": "未登录",
|
||||
"ui.agent_chat_state_off": "未连接",
|
||||
"ui.agent_chat_state_starting": "正在启动…",
|
||||
"ui.agent_chat_state_ready": "就绪",
|
||||
"ui.agent_chat_state_busy": "工作中…",
|
||||
"ui.agent_chat_state_auth": "需要登录",
|
||||
"ui.agent_chat_state_exited": "智能体已停止",
|
||||
"ui.agent_chat_changes_of": "更改 {n}/{limit}",
|
||||
"ui.agent_chat_auth_title": "登录智能体",
|
||||
"ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;SolidCraft 从不接触你的密码或密钥。",
|
||||
"ui.agent_chat_auth_none": "该智能体未在此提供登录方式:请按照它自己的文档登录。",
|
||||
"ui.agent_chat_auth_terminal": "在终端中运行以下命令,然后再次点击连接:",
|
||||
"ui.agent_chat_auth_agent": "使用 {name} 登录",
|
||||
"ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 SolidCraft。",
|
||||
"ui.agent_chat_auth_retry": "我已登录:重新连接",
|
||||
"ui.agent_chat_copy": "复制",
|
||||
"ui.agent_chat_permission": "智能体请求运行:{title}",
|
||||
"ui.agent_chat_answered": "回答:{option}",
|
||||
"ui.agent_chat_approval": "允许智能体执行此操作吗?",
|
||||
"ui.agent_chat_allow": "允许",
|
||||
"ui.agent_chat_deny": "拒绝",
|
||||
"ui.agent_chat_allowed": "已允许",
|
||||
"ui.agent_chat_denied": "已拒绝",
|
||||
"ui.agent_chat_changes": "{n} 项更改,一步撤销",
|
||||
"ui.agent_chat_undo_changes": "撤销这些更改",
|
||||
"ui.agent_chat_undone": "(已撤销)",
|
||||
"ui.agent_chat_undo_label": "智能体聊天:{message}",
|
||||
"ui.agent_chat_limit": "智能体已达到每条消息 {limit} 项更改的上限。再发送一条消息可让它继续。",
|
||||
"ui.agent_chat_refused": "不允许智能体执行:{reason}",
|
||||
"ui.agent_chat_wait_turn": "智能体仍在处理上一条消息:请等待或点击取消。",
|
||||
"ui.agent_chat_placeholder": "让智能体修改设计…",
|
||||
"ui.agent_chat_attach_view": "附加视图",
|
||||
"ui.agent_chat_attach_view_tip": "随消息发送当前视图的图片",
|
||||
"ui.agent_chat_no_images": "该智能体不接收图片",
|
||||
"ui.agent_chat_view_attached": "已附加视图",
|
||||
"ui.agent_chat_no_cli": "在应用旁边未找到 solidcraft-cli,因此智能体无法访问设计。",
|
||||
"ui.agent_chat_no_control": "智能体聊天的端点未能启动,因此智能体无法访问设计。",
|
||||
"ui.agent_chat_exited": "智能体已停止。",
|
||||
"ui.agent_chat_failed_start": "智能体无法启动:{error}",
|
||||
"ui.agent_chat_intro": "与你已在使用的编程智能体或你选择的模型端点聊天。它通过 SolidCraft 的工具处理打开的设计:更改即时显示,每条消息的更改可一步撤销。无需询问即可执行的范围在“首选项 > AI”中设置。",
|
||||
"ui.agent_chat_privacy": "除了你以自己的账户运行的智能体之外,不会向任何地方发送任何内容。",
|
||||
"ui.agent_chat_none_found_short": "未找到智能体",
|
||||
"ui.ai_add": "添加",
|
||||
"ui.ai_add_agent": "添加智能体…",
|
||||
"ui.ai_add_agent_title": "添加智能体",
|
||||
"ui.ai_add_api": "OpenAI 兼容 API",
|
||||
"ui.ai_add_custom": "自定义命令",
|
||||
"ui.ai_added": "已添加",
|
||||
"ui.ai_add_installed": "已安装的智能体",
|
||||
"ui.ai_add_installed_note": "为已安装的智能体添加一个自己的条目,例如使用其他参数。已安装的智能体会自动列出。",
|
||||
"ui.ai_agent_mode_tip": "智能体自己的模式:它自己的工具(shell、文件编辑)遵循此模式。SolidCraft 的工具遵循“首选项 > AI”中的权限。",
|
||||
"ui.ai_agents": "智能体",
|
||||
"ui.ai_always_allow": "始终允许",
|
||||
"ui.ai_always_allowed": "始终允许的项目",
|
||||
"ui.ai_always_allowed_none": "尚无始终允许的项目。你在聊天中选择“始终允许”的回答会列在这里。",
|
||||
"ui.ai_always_allow_tip": "从现在起允许该智能体使用 {tool},不再询问",
|
||||
"ui.ai_auto_allowed": "已自动允许:{title}({why})",
|
||||
"ui.ai_auto_mode": "你的权限设置",
|
||||
"ui.ai_auto_ours": "SolidCraft 会自行检查它自己的工具",
|
||||
"ui.ai_auto_remembered": "你选择了始终允许",
|
||||
"ui.ai_bad_url": "请输入以 http:// 或 https:// 开头的 URL",
|
||||
"ui.ai_base_url": "基础 URL",
|
||||
"ui.ai_button": "AI",
|
||||
"ui.ai_copy_suffix": "副本",
|
||||
"ui.ai_custom_url": "自定义 URL",
|
||||
"ui.ai_default": "默认",
|
||||
"ui.ai_http_busy": "端点繁忙或已达上限,请稍后再试",
|
||||
"ui.ai_http_key": "端点拒绝了密钥:请在“首选项 > AI”中检查",
|
||||
"ui.ai_http_not_found": "找不到该端点或模型:请检查基础 URL 和模型名称",
|
||||
"ui.ai_key": "API 密钥",
|
||||
"ui.ai_keychain_error": "系统钥匙串拒绝:{error}",
|
||||
"ui.ai_key_hint": "可选;保存在系统钥匙串中",
|
||||
"ui.ai_key_needs_https": "密钥只会通过 HTTPS 发送,或发送到本机。",
|
||||
"ui.ai_key_note": "保存在系统钥匙串中,绝不保存在 SolidCraft 的设置里。本地服务器可留空。",
|
||||
"ui.ai_mode_all": "全部允许",
|
||||
"ui.ai_mode_all_note": "不再询问。崩溃报告仍不可访问,文件夹限制仍然有效。",
|
||||
"ui.ai_mode_all_warning": "智能体可以不经询问保存、覆盖、导出和替换你的设计。只在你信任的智能体和已保存的设计上使用。",
|
||||
"ui.ai_mode_ask": "每次询问",
|
||||
"ui.ai_mode_ask_note": "对设计的每项更改和每个文件操作都要等待你的允许。",
|
||||
"ui.ai_mode_edits": "自动允许模型编辑",
|
||||
"ui.ai_mode_edits_note": "建模命令无需询问即可运行;保存、打开、导出等文件操作或破坏性操作仍会询问。",
|
||||
"ui.ai_mode_global": "按上方设置",
|
||||
"ui.ai_model": "模型",
|
||||
"ui.ai_modes_scope": "这些设置适用于 SolidCraft 的工具。智能体自己的工具(它的 shell、文件编辑)遵循智能体自己的模式;如果智能体提供模式,可在聊天面板标题栏中选择。",
|
||||
"ui.ai_name": "名称",
|
||||
"ui.ai_need_command": "请输入启动智能体的命令",
|
||||
"ui.ai_need_model": "请输入模型名称",
|
||||
"ui.ai_no_answer": "智能体未及时响应",
|
||||
"ui.ai_no_http": "此版本无法访问 API 端点",
|
||||
"ui.ai_no_keychain": "系统钥匙串不可用,无法保存密钥。无需密钥的本地服务器仍可使用。",
|
||||
"ui.ai_panel": "面板",
|
||||
"ui.ai_permissions": "权限",
|
||||
"ui.ai_preset": "预设",
|
||||
"ui.ai_remove": "移除",
|
||||
"ui.ai_request_limit": "每条消息 24 次请求后已停止;再发送一条消息即可继续",
|
||||
"ui.ai_reset": "重置",
|
||||
"ui.ai_settings": "AI 设置…",
|
||||
"ui.ai_show_features": "显示 AI 功能",
|
||||
"ui.ai_show_features_note": "智能体聊天:一个面板,由你已在使用的编程智能体或你选择的模型端点处理打开的设计。关闭后:没有 AI 按钮、快捷键、菜单项或面板,也不会查找或启动任何程序。",
|
||||
"ui.ai_switch_tip": "更改此对话的智能体",
|
||||
"ui.ai_test": "测试",
|
||||
"ui.ai_test_acp_ok": "已响应:{agent}",
|
||||
"ui.ai_test_acp_tip": "短暂启动智能体并请求一个会话,然后结束它",
|
||||
"ui.ai_test_api_tip": "向端点查询其模型列表",
|
||||
"ui.ai_testing": "正在测试…",
|
||||
"ui.ai_test_no_model": "已连接:{count} 个模型,但没有 {model}",
|
||||
"ui.ai_test_ok": "已连接:{count} 个模型",
|
||||
"ui.ai_toolbar_button": "在工具栏显示按钮",
|
||||
"ui.ai_toolbar_button_note": "工具栏右端的 AI 按钮。Ctrl+Shift+A 和视图菜单仍可打开面板。",
|
||||
"ui.ai_tool_done": "完成",
|
||||
"ui.ai_tool_failed": "失败",
|
||||
"ui.ai_tool_running": "运行中",
|
||||
"ui.ai_tool_unfold": "点击显示返回内容",
|
||||
"ui.ai_tool_waiting": "等待中",
|
||||
"ui.ai_undo_tip": "将智能体为此消息所做的全部更改一步撤销",
|
||||
"AI": "AI",
|
||||
"ui.all": "全部",
|
||||
"ui.angle": "角度",
|
||||
"ui.angle_decimals": "角度小数位",
|
||||
@@ -308,6 +499,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": "装配",
|
||||
@@ -337,10 +538,47 @@
|
||||
"ui.click_selects_whole_bodies": "单击选择整个实体",
|
||||
"ui.click_then_press_key": "单击,然后按新按键",
|
||||
"ui.close": "关闭",
|
||||
"ui.material_review.title": "检查物理材料数据",
|
||||
"ui.material_review.scope": "仅检查候选数据:不指定或保存。数值验证不等于工程认证。",
|
||||
"ui.material_review.input": "粘贴架构版本1的材料卡(JSON)",
|
||||
"ui.material_review.review": "检查材料卡",
|
||||
"ui.material_review.limit": "材料卡超过256 KiB输入限制。",
|
||||
"ui.material_review.unknown": "未知",
|
||||
"ui.material_review.properties": "属性",
|
||||
"ui.material_review.conditions": "声明的条件",
|
||||
"ui.material_review.source": "来源",
|
||||
"ui.material_review.axes": "材料局部坐标轴;不指定实体方向。",
|
||||
"ui.material_review.derived": "推导的各向同性模量",
|
||||
"ui.material_review.claims": "来源和许可证声明未经核实。缺失值保持未知。",
|
||||
"ui.material_review.density": "密度",
|
||||
"ui.material_review.elasticity": "弹性",
|
||||
"ui.material_review.tensile_strength": "抗拉强度",
|
||||
"ui.material_review.compressive_strength": "抗压强度",
|
||||
"ui.material_review.yield_strength": "屈服强度",
|
||||
"ui.material_review.shear_strength": "抗剪强度",
|
||||
"ui.material_review.thermal_conductivity": "导热系数",
|
||||
"ui.material_review.thermal_expansion": "热膨胀",
|
||||
"ui.material_review.specific_heat": "比热容",
|
||||
"ui.material_review.moisture_unit": "kg 水 / kg 干燥材料",
|
||||
"ui.coil": "螺旋",
|
||||
"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 +789,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 +807,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 +985,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 +1175,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 +1209,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 +1239,143 @@
|
||||
"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} 已发布",
|
||||
"install.menu": "安装应用…",
|
||||
"install.button_tip": "将 SolidCraft 安装为应用:在独立窗口中打开,离线也能使用",
|
||||
"install.title": "安装 SolidCraft",
|
||||
"install.steps_intro": "Safari 通过自身的菜单安装网页应用:",
|
||||
"install.home_screen_1": "轻点“共享”(若不在工具栏上,则在 … 菜单中)。",
|
||||
"install.home_screen_2": "选择“添加到主屏幕”。",
|
||||
"install.home_screen_3": "轻点“添加”。",
|
||||
"install.home_screen_after": "之后 SolidCraft 从主屏幕上的图标打开,不显示 Safari 的工具栏,离线也能使用。",
|
||||
"install.dock_1": "选择“文件”›“添加到程序坞”。",
|
||||
"install.dock_2": "点按“添加”。",
|
||||
"install.dock_after": "之后 SolidCraft 从程序坞在独立窗口中打开,离线也能使用。",
|
||||
"install.done": "完成",
|
||||
"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_too_large": "超出图像限制:每边 4096 像素,总计 4,194,304 像素。",
|
||||
"render.image_use_largest": "使用此宽高比下的最大尺寸:",
|
||||
"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": "每个所选实体移入各自的新组件,位置保持不变。",
|
||||
"Circumference": "周长",
|
||||
"Arc length": "弧长",
|
||||
"Slot width": "槽宽",
|
||||
"Centre to centre": "中心距",
|
||||
"Overall length": "总长",
|
||||
"Min": "最小",
|
||||
"Max": "最大"
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+6
-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)?;
|
||||
@@ -396,6 +235,9 @@ impl crate::Feature {
|
||||
mapped_name(n, parameters);
|
||||
}
|
||||
}
|
||||
for e in sketch.text_exprs_mut() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
for link in &mut sketch.links {
|
||||
remap_link(&mut link.source, ids, names, bodies, 0);
|
||||
for n in link.face_name.iter_mut().chain(&mut link.edge_name) {
|
||||
@@ -410,6 +252,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,248 @@
|
||||
//! 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())
|
||||
}
|
||||
}
|
||||
|
||||
/// Most refinement steps view-dependent display refinement takes past the chosen level (#1320):
|
||||
/// each halves the chord tolerance, so the finest is 1/16 of the chosen level's.
|
||||
pub const MAX_REFINE: u8 = 4;
|
||||
|
||||
/// Screen-space error (physical pixels) above which a body is refined: a facet's sag shows.
|
||||
pub const REFINE_PX: f64 = 1.0;
|
||||
/// Error a refinement aims for (below [`REFINE_PX`], so a body just refined is not refined again
|
||||
/// at the next small zoom step).
|
||||
pub const TARGET_PX: f64 = 0.75;
|
||||
/// Error below which a refined body steps back toward the chosen level. A refinement lands
|
||||
/// between half [`TARGET_PX`] and [`TARGET_PX`], and a step back between this and twice this, so
|
||||
/// zooming out or in by up to 2× from either never flips the level back.
|
||||
pub const COARSEN_PX: f64 = 0.175;
|
||||
|
||||
impl DisplayQuality {
|
||||
/// Chord tolerance (mm) of a body of this size refined `k` steps past this level: halved
|
||||
/// per step.
|
||||
pub fn refined_chord(self, size: f64, k: u8) -> f64 {
|
||||
self.chord(size) / f64::from(1u32 << k.min(MAX_REFINE))
|
||||
}
|
||||
|
||||
/// Facet angle (radians) `k` steps past this level: an arc's sag goes with the square of its
|
||||
/// angle, so the angle shrinks by √2 per step, keeping pace with the chord.
|
||||
pub fn refined_angle(self, k: u8) -> f64 {
|
||||
self.angle() / std::f64::consts::SQRT_2.powi(i32::from(k.min(MAX_REFINE)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The refinement step a body should be drawn at (0: the chosen level), from `err_px`: the
|
||||
/// screen-space error of the chosen level, its chord tolerance in physical pixels where the body
|
||||
/// is nearest the eye (0 for a body off screen or too small to matter). `current` is the step
|
||||
/// drawn now; steps halve the error. A body is refined once its error passes [`REFINE_PX`], to the
|
||||
/// first step under [`TARGET_PX`], and steps back only once the error falls under [`COARSEN_PX`],
|
||||
/// to the coarsest step that stays under twice that: the band between keeps an orbit or a small
|
||||
/// zoom from flipping levels. Never past `max` (at most [`MAX_REFINE`]).
|
||||
pub fn refinement_for(err_px: f64, current: u8, max: u8) -> u8 {
|
||||
let max = max.min(MAX_REFINE);
|
||||
let current = current.min(max);
|
||||
if !(err_px.is_finite() && err_px > 0.0) {
|
||||
return 0;
|
||||
}
|
||||
let err = |k: u8| err_px / f64::from(1u32 << k);
|
||||
if err(current) > REFINE_PX {
|
||||
(current..=max).find(|&k| err(k) <= TARGET_PX).unwrap_or(max)
|
||||
} else if err(current) < COARSEN_PX {
|
||||
(0..=current).find(|&k| err(k) <= 2.0 * COARSEN_PX).unwrap_or(current)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refinement_follows_the_screen_space_error_with_hysteresis() {
|
||||
// Off screen or tiny: the chosen level.
|
||||
assert_eq!(refinement_for(0.0, 3, MAX_REFINE), 0);
|
||||
assert_eq!(refinement_for(f64::NAN, 2, MAX_REFINE), 0);
|
||||
// Fine already: stays.
|
||||
assert_eq!(refinement_for(0.6, 0, MAX_REFINE), 0);
|
||||
assert_eq!(refinement_for(1.0, 0, MAX_REFINE), 0);
|
||||
// Past a pixel: the first step under the target (each halves the error).
|
||||
assert_eq!(refinement_for(1.2, 0, MAX_REFINE), 1);
|
||||
assert_eq!(refinement_for(5.0, 0, MAX_REFINE), 3);
|
||||
assert_eq!(refinement_for(5.0, 1, MAX_REFINE), 3);
|
||||
// Capped.
|
||||
assert_eq!(refinement_for(1e6, 0, MAX_REFINE), MAX_REFINE);
|
||||
assert_eq!(refinement_for(1e6, 0, 2), 2);
|
||||
assert_eq!(refinement_for(1e6, 9, 99), MAX_REFINE);
|
||||
// Zooming back out: kept until the error is well under a pixel, then the coarsest step
|
||||
// that stays under half a pixel.
|
||||
assert_eq!(refinement_for(5.0, 3, MAX_REFINE), 3);
|
||||
assert_eq!(refinement_for(2.5, 3, MAX_REFINE), 3, "0.31 px at step 3: in the band");
|
||||
assert_eq!(refinement_for(1.2, 3, MAX_REFINE), 2, "0.15 px at step 3: down to 0.3 px");
|
||||
assert_eq!(refinement_for(0.05, 3, MAX_REFINE), 0);
|
||||
// No flicker: any error, once settled, gives the same step again, and a zoom by up to
|
||||
// 2x either way from where a level was chosen keeps it.
|
||||
let mut e = 0.01;
|
||||
while e < 1e4 {
|
||||
for start in 0..=MAX_REFINE {
|
||||
let k = refinement_for(e, start, MAX_REFINE);
|
||||
assert_eq!(refinement_for(e, k, MAX_REFINE), k, "error {e}, from {start}");
|
||||
// Settled by zooming in (from the chosen level) or out (from the finest).
|
||||
if start == 0 || start == MAX_REFINE {
|
||||
for f in [0.5, 0.7, 1.0, 1.4, 2.0] {
|
||||
let back = refinement_for(e * f, k, MAX_REFINE);
|
||||
let zoomed_in = f >= 1.0;
|
||||
assert!(
|
||||
back == k || (zoomed_in && back > k && start == 0) || (!zoomed_in && back < k && start == MAX_REFINE),
|
||||
"error {e} x{f} from {start}: {k} -> {back}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
e *= 1.13;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refined_steps_halve_the_chord() {
|
||||
let q = DisplayQuality::Normal;
|
||||
assert_eq!(q.refined_chord(200.0, 0), q.chord(200.0));
|
||||
assert_eq!(q.refined_chord(200.0, 2), q.chord(200.0) / 4.0);
|
||||
assert_eq!(q.refined_chord(200.0, 99), q.chord(200.0) / 16.0);
|
||||
assert!((q.refined_angle(2) - q.angle() / 2.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
+731
-355
File diff suppressed because it is too large
Load Diff
+488
-82
@@ -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,39 @@ 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 plan and its mesh `k` steps finer than `q` (view-dependent refinement, #1320;
|
||||
/// see [`crate::display_quality::refinement_for`]). Step 0 is the cached plan at `q`; finer
|
||||
/// steps are made on each call and kept by the viewport, which evicts them when it runs over
|
||||
/// its budget. `None` when the body has no plan at that step.
|
||||
pub fn refined_display(&self, q: crate::display_quality::DisplayQuality, k: u8) -> Option<(Arc<TessPlan>, Arc<Mesh>)> {
|
||||
if k == 0 {
|
||||
return Some((self.display_plan_at(q)?, self.plan_mesh_at(q)?));
|
||||
}
|
||||
let plan = match self.body.display_plan_angular(q.refined_chord(self.body.size(), k), q.refined_angle(k)) {
|
||||
Ok(plan) => plan,
|
||||
Err(e) => {
|
||||
log::debug!("{}: no display plan refined {k} steps ({e})", self.name);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mesh = self.body.display_mesh_of(&plan).ok()?;
|
||||
Some((Arc::new(plan), Arc::new(mesh)))
|
||||
}
|
||||
|
||||
/// The display mesh's chord tolerance: 1/1000 of the body's size.
|
||||
@@ -276,6 +311,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
|
||||
@@ -684,11 +737,13 @@ pub struct Model {
|
||||
pub no_cache: bool,
|
||||
}
|
||||
|
||||
/// Parameters a feature uses directly (inputs and sketch dimensions), sorted.
|
||||
/// Parameters a feature uses directly (inputs, sketch dimensions and the expressions of a
|
||||
/// sketch's texts), sorted.
|
||||
fn param_names(f: &Feature) -> Vec<String> {
|
||||
let mut names: Vec<String> = f.kind.expressions().iter().flat_map(|e| expr::references(e)).collect();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
|
||||
}
|
||||
names.sort();
|
||||
names.dedup();
|
||||
@@ -1201,6 +1256,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()),
|
||||
@@ -1252,7 +1309,7 @@ fn point(vals: &BTreeMap<String, Value>, p: &crate::PointExpr) -> Result<Vec3> {
|
||||
if [v.x, v.y, v.z].iter().all(|c| c.is_finite() && c.abs() < 1e9) { Ok(v) } else { Err(DocError::Invalid("the position is out of range".into())) }
|
||||
}
|
||||
|
||||
fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
|
||||
pub(crate) fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
|
||||
let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0);
|
||||
if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) {
|
||||
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))))?;
|
||||
@@ -1261,6 +1318,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() {
|
||||
@@ -1647,11 +1715,62 @@ fn region_samples(r: &Region2) -> Vec<Vec2> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The region with each hole filled whose inside the tool would sweep with no change to the
|
||||
/// targets: all empty for a cut (the profile drawn round a hole in the part), all material for a
|
||||
/// join. Then the tool's walls don't lie on the part's walls there (the boolean can't intersect
|
||||
/// walls lying on each other, #1360), and what it cuts or joins is the same. A hole is filled only
|
||||
/// when points across it at levels through the tool are all empty (or all material) and no
|
||||
/// vertex of the targets lies inside the column over it (an island in it, a floor).
|
||||
fn fill_void_holes(st: &ModelState, r: &Region2, op: Operation, targets: &[String], plane: &Plane, (lo, hi): (f64, f64)) -> Region2 {
|
||||
if r.holes.is_empty() || !matches!(op, Operation::Cut | Operation::Join) || hi - lo <= 0.0 {
|
||||
return r.clone();
|
||||
}
|
||||
let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).collect();
|
||||
if bodies.is_empty() {
|
||||
return r.clone();
|
||||
}
|
||||
let meshes: Vec<Arc<Mesh>> = bodies.iter().map(|b| b.mesh()).collect();
|
||||
let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max);
|
||||
let margin = (size * 1e-4).max(1e-4);
|
||||
let n = plane.normal();
|
||||
let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p));
|
||||
let want = op == Operation::Join;
|
||||
let mut holes = Vec::with_capacity(r.holes.len());
|
||||
for h in &r.holes {
|
||||
let column = Region2 { outer: h.ccw(), holes: vec![] };
|
||||
let samples = region_samples(&column);
|
||||
let swept = (1..=8).all(|k| {
|
||||
let z = lo + (hi - lo) * (k as f64 - 0.5) / 8.0;
|
||||
samples.iter().all(|s| inside_any(plane.to_world(*s) + n * z) == want)
|
||||
});
|
||||
// No boundary of the targets within the column (shrunk off its wall, short of its ends).
|
||||
let clear = swept
|
||||
&& meshes.iter().all(|m| {
|
||||
m.positions.iter().all(|p| {
|
||||
let z = (*p - plane.origin).dot(n);
|
||||
let q = plane.to_local(*p);
|
||||
!(z > lo + margin
|
||||
&& z < hi - margin
|
||||
&& column.contains(q)
|
||||
&& h.segs.iter().all(|sg| sg.point_at(sg.closest_param(q)).dist(q) > margin))
|
||||
})
|
||||
});
|
||||
if !clear {
|
||||
holes.push(h.clone());
|
||||
}
|
||||
}
|
||||
Region2 { outer: r.outer.clone(), holes }
|
||||
}
|
||||
|
||||
/// Grow profile edges that lie on a face of a target body away from the profile, by a small
|
||||
/// amount, when the space they grow into is already material (join) or empty (cut). `probe`
|
||||
/// maps a sketch point to the world points where the tool would be (along the extrude, around
|
||||
/// the revolve). Only loops of straight edges are adjusted.
|
||||
/// the revolve). Loops of lines and arcs are adjusted: a line moves out, an arc keeps its centre
|
||||
/// and changes its radius (a rounded corner of a face cut whole, #1275). Arcs move only where
|
||||
/// both of their ends meet tangent neighbours that move with them; otherwise the loop is left
|
||||
/// as it is.
|
||||
fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, targets: &[String], probe: &dyn Fn(Vec2) -> Vec<Vec3>) -> Region2 {
|
||||
use solidcraft_geom::Seg2;
|
||||
if !matches!(op, Operation::Cut | Operation::Join) {
|
||||
return r.clone();
|
||||
}
|
||||
@@ -1663,55 +1782,94 @@ fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, target
|
||||
let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max);
|
||||
let delta = (size * 0.005).max(1e-3);
|
||||
let lp = r.outer.ccw();
|
||||
let lines: Option<Vec<(Vec2, Vec2)>> =
|
||||
lp.segs.iter().map(|s| if let solidcraft_geom::Seg2::Line { a, b } = *s { Some((a, b)) } else { None }).collect();
|
||||
let Some(lines) = lines else { return r.clone() };
|
||||
let segs = &lp.segs;
|
||||
if segs.is_empty() || !segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
|
||||
return r.clone();
|
||||
}
|
||||
// The outward normal (away from the profile) at parameter `t` of a segment of the
|
||||
// counterclockwise loop: the tangent turned clockwise.
|
||||
let out_at = |s: &Seg2, t: f64| -> Option<Vec2> {
|
||||
match *s {
|
||||
Seg2::Line { a, b } => (b - a).normalized().map(|d| Vec2::new(d.y, -d.x)),
|
||||
Seg2::Arc { start, sweep, .. } => Some(Vec2::from_angle(start + sweep * t) * sweep.signum()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p));
|
||||
let mut grow = vec![0.0; lines.len()];
|
||||
let mut grow = vec![0.0; segs.len()];
|
||||
let mut any = false;
|
||||
for (i, (a, b)) in lines.iter().enumerate() {
|
||||
let Some(dir) = (*b - *a).normalized() else { continue };
|
||||
let out = Vec2::new(dir.y, -dir.x);
|
||||
for (i, seg) in segs.iter().enumerate() {
|
||||
if let Seg2::Arc { radius, sweep, .. } = *seg
|
||||
&& sweep < 0.0
|
||||
&& radius <= 2.0 * delta
|
||||
{
|
||||
// A concave arc too small to shrink by the step.
|
||||
continue;
|
||||
}
|
||||
// Is the edge on a body face? Material on exactly one side of it.
|
||||
let mids = [a.lerp(*b, 0.25), a.lerp(*b, 0.5), a.lerp(*b, 0.75)];
|
||||
let side = |k: f64| mids.iter().flat_map(|m| probe(*m + out * (delta * k))).map(inside_any).collect::<Vec<bool>>();
|
||||
let mids: Vec<(Vec2, Vec2)> = [0.25, 0.5, 0.75].iter().filter_map(|t| Some((seg.point_at(*t), out_at(seg, *t)?))).collect();
|
||||
if mids.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let side = |k: f64| mids.iter().flat_map(|(m, out)| probe(*m + *out * (delta * k))).map(inside_any).collect::<Vec<bool>>();
|
||||
let (outer, inner) = (side(0.5), side(-0.5));
|
||||
let want_outer = op == Operation::Join;
|
||||
let on_face = outer.iter().all(|x| *x == want_outer) && inner.iter().any(|x| *x != want_outer);
|
||||
let room = side(1.0).iter().all(|x| *x == want_outer);
|
||||
if on_face && room {
|
||||
if let Some(g) = grow.get_mut(i) {
|
||||
*g = delta;
|
||||
}
|
||||
if on_face
|
||||
&& room
|
||||
&& let Some(g) = grow.get_mut(i)
|
||||
{
|
||||
*g = delta;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return r.clone();
|
||||
}
|
||||
// Offset the chosen lines and re-intersect neighbours.
|
||||
let n = lines.len();
|
||||
let shifted: Vec<(Vec2, Vec2)> = lines
|
||||
.iter()
|
||||
.zip(&grow)
|
||||
.map(|((a, b), g)| {
|
||||
let dir = (*b - *a).normalized().unwrap_or(Vec2::X);
|
||||
let out = Vec2::new(dir.y, -dir.x);
|
||||
(*a + out * *g, dir)
|
||||
})
|
||||
.collect();
|
||||
let mut pts = Vec::with_capacity(n);
|
||||
let n = segs.len();
|
||||
let tangent = |a: &Seg2, b: &Seg2| a.end_tangent().cross(b.start_tangent()).abs() < 1e-6 && a.end_tangent().dot(b.start_tangent()) > 0.0;
|
||||
// Where segment `i` starts once grown: the corner of it and the one before.
|
||||
let mut starts = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let (Some(&(p0, d0)), Some(&(p1, d1))) = (shifted.get((i + n - 1) % n), shifted.get(i)) else { return r.clone() };
|
||||
let (Some(prev), Some(cur), Some(&g0), Some(&g1)) = (segs.get((i + n - 1) % n), segs.get(i), grow.get((i + n - 1) % n), grow.get(i)) else {
|
||||
return r.clone();
|
||||
};
|
||||
let corner = cur.start();
|
||||
if g0 == 0.0 && g1 == 0.0 {
|
||||
starts.push(corner);
|
||||
continue;
|
||||
}
|
||||
if tangent(prev, cur) {
|
||||
// Moved together, a tangent joint moves along the common normal and stays tangent.
|
||||
if g0 != g1 {
|
||||
return r.clone();
|
||||
}
|
||||
let Some(out) = out_at(cur, 0.0) else { return r.clone() };
|
||||
starts.push(corner + out * g1);
|
||||
continue;
|
||||
}
|
||||
let (Seg2::Line { a: a0, b: b0 }, Seg2::Line { a: a1, b: b1 }) = (*prev, *cur) else { return r.clone() };
|
||||
let (Some(d0), Some(d1)) = ((b0 - a0).normalized(), (b1 - a1).normalized()) else { return r.clone() };
|
||||
let (p0, p1) = (a0 + Vec2::new(d0.y, -d0.x) * g0, a1 + Vec2::new(d1.y, -d1.x) * g1);
|
||||
let den = d0.cross(d1);
|
||||
if den.abs() < 1e-12 {
|
||||
pts.push(p1);
|
||||
starts.push(p1);
|
||||
continue;
|
||||
}
|
||||
let t = (p1 - p0).cross(d1) / den;
|
||||
pts.push(p0 + d0 * t);
|
||||
starts.push(p0 + d0 * t);
|
||||
}
|
||||
Region2 { outer: solidcraft_geom::Loop2::polygon(&pts), holes: r.holes.clone() }
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for (i, seg) in segs.iter().enumerate() {
|
||||
let (Some(&a), Some(&b), Some(&g)) = (starts.get(i), starts.get((i + 1) % n), grow.get(i)) else { return r.clone() };
|
||||
out.push(match *seg {
|
||||
Seg2::Line { .. } => Seg2::Line { a, b },
|
||||
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius: radius + g * sweep.signum(), start, sweep },
|
||||
_ => return r.clone(),
|
||||
});
|
||||
}
|
||||
Region2 { outer: solidcraft_geom::Loop2 { segs: out }, holes: r.holes.clone() }
|
||||
}
|
||||
|
||||
/// Booleans struggle with coincident faces (a cut starting on the face it cuts, a boss
|
||||
@@ -1771,6 +1929,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 +2110,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 +2387,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 +2440,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 +2701,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 +2743,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 +2802,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 {
|
||||
@@ -2601,6 +2874,8 @@ fn feature_tools_with_constructor(
|
||||
let mut untrimmed = extent.to.is_none() && extent.from.is_none() && !extent.through_all;
|
||||
// The footprint of each tool in the sketch: a tool lies in the prism over it.
|
||||
let mut footprints = Vec::with_capacity(regions.len());
|
||||
// Each tool's extent along the normal (they differ where one is run on into a face).
|
||||
let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len());
|
||||
for r in ®ions {
|
||||
if *operation == Operation::Join {
|
||||
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
|
||||
@@ -2614,6 +2889,7 @@ fn feature_tools_with_constructor(
|
||||
// the sewable seam with a coincident curved intersection.
|
||||
tools.extend(exact);
|
||||
footprints.push(r.clone());
|
||||
extents.push((lo + off, hi + off));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -2621,18 +2897,31 @@ fn feature_tools_with_constructor(
|
||||
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
|
||||
let original = r;
|
||||
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
|
||||
let r = fill_void_holes(st, &r, *operation, targets, &ss.plane, (l2, h2));
|
||||
untrimmed &= l2 == lo + off && h2 == hi + off && &r == original;
|
||||
tools.extend(centered_extrude(&ss.plane, &r, l2, h2, Some(&ss))?);
|
||||
footprints.push(r);
|
||||
extents.push((l2, h2));
|
||||
}
|
||||
// A cut by profiles apart in the sketch (the letters of a text): each tool lies in the
|
||||
// prism over its footprint, so the tools are apart too (whatever their heights, and
|
||||
// trimmed or not). The body is then cut once by all of them rather than once per
|
||||
// profile, each cut working on a body grown by the one before.
|
||||
// (Sheet metal keeps its own way of cutting, tool by tool, to keep its flat pattern.)
|
||||
// A cut or a join by profiles apart in the sketch (the letters of a text): each tool
|
||||
// lies in the prism over its footprint, so the tools are apart too (whatever their
|
||||
// heights, and trimmed or not). The body is then cut once by all of them, or joined to
|
||||
// all of them at once, rather than once 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 its own way of cutting, tool by tool, to keep its flat pattern.)
|
||||
let sheet =
|
||||
if targets.is_empty() { !st.sheets.is_empty() } else { targets.iter().any(|t| st.sheets.iter().any(|sheet| &sheet.body == t)) };
|
||||
if *operation == Operation::Cut && !sheet && tools.len() > 1 && regions_apart(&footprints, APART_GAP) {
|
||||
let one_target = if targets.is_empty() { st.bodies.len() == 1 } else { targets.len() == 1 };
|
||||
// A join at once only of tools alike in height (one prism of them all, which the
|
||||
// boolean joins exactly): tools that start at different levels (some run on into the
|
||||
// face, one hanging over its edge) keep joining one by one.
|
||||
let alike = extents.windows(2).all(|w| (w[0].0 - w[1].0).abs() < 1e-9 && (w[0].1 - w[1].1).abs() < 1e-9);
|
||||
let at_once = match operation {
|
||||
Operation::Cut => !sheet,
|
||||
Operation::Join => one_target && !sheet && alike,
|
||||
_ => false,
|
||||
};
|
||||
if at_once && tools.len() > 1 && regions_apart(&footprints, APART_GAP) {
|
||||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||||
if let Ok(Some(one)) = kernel::join_apart(&bodies) {
|
||||
tools = vec![Traced::step(one, &tools.iter().collect::<Vec<_>>())];
|
||||
@@ -2825,6 +3114,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 +3170,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 +3180,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 +3199,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 +3250,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 +3471,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 +4104,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 +4122,10 @@ 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::SurfaceBlend { .. }
|
||||
| FeatureKind::SurfaceOffset { .. }
|
||||
| FeatureKind::SurfaceRuled { .. }
|
||||
| FeatureKind::SurfaceUntrim { .. } => surface::eval(doc, vals, f, st, warning),
|
||||
FeatureKind::Emboss { .. }
|
||||
| FeatureKind::Rib { .. }
|
||||
| FeatureKind::ReplaceFace { .. }
|
||||
@@ -3821,6 +4147,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||
};
|
||||
let mut sk = sketch.clone();
|
||||
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
|
||||
// ones its expressions give come first.
|
||||
doc.apply_text_values(vals, &mut sk)?;
|
||||
let mut warns = match anchor {
|
||||
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor),
|
||||
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk),
|
||||
@@ -3857,7 +4186,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 +4196,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 +4498,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 +4586,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 +4598,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);
|
||||
@@ -4416,15 +4750,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Import { step, .. } => {
|
||||
FeatureKind::Import { step, skip, .. } => {
|
||||
let imp = kernel::step_import_shared(step)?;
|
||||
let digest = crate::config::payload_digest(step);
|
||||
// Files from board and assembly tools give thousands of bodies the same name: name
|
||||
// them from a set, not by searching the bodies for every candidate (cubic).
|
||||
let mut names = BodyNames::new(st);
|
||||
for (k, b) in imp.bodies.iter().enumerate() {
|
||||
let base = f.body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name);
|
||||
let name = names.unique(base);
|
||||
let names = import_body_names(st, &f.body_names, &imp, skip);
|
||||
for (b, name) in imp.bodies.iter().zip(names) {
|
||||
let Some(name) = name else { continue };
|
||||
let b = crate::lineage::numbered_tool(b.body.clone().with_import_certificate(&digest, f.id)?);
|
||||
st.bodies.push(ModelBody::traced_new(name, b, f.id));
|
||||
}
|
||||
@@ -4457,6 +4788,27 @@ fn mesh_bodies(meshes: &[crate::MeshData]) -> Result<Vec<(String, Body)>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The names an Import feature gives the file's bodies, added after the bodies of `st`, in the
|
||||
/// file's order: `None` for a body it skips (a later instance of a repeated part). `body_names`
|
||||
/// are the feature's stored names (by index), else the file's own.
|
||||
pub fn import_body_names(st: &ModelState, body_names: &[String], imp: &kernel::StepImport, skip: &[u64]) -> Vec<Option<String>> {
|
||||
let skip: std::collections::HashSet<u64> = skip.iter().copied().collect();
|
||||
// Files from board and assembly tools give thousands of bodies the same name: name them
|
||||
// from a set, not by searching the bodies for every candidate (cubic).
|
||||
let mut names = BodyNames::new(st);
|
||||
imp.bodies
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, b)| {
|
||||
if skip.contains(&(k as u64)) {
|
||||
return None;
|
||||
}
|
||||
let base = body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name);
|
||||
Some(names.unique(base))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`unique_body_name`] for many bodies in a row: the same names, in time linear in the bodies.
|
||||
struct BodyNames {
|
||||
taken: std::collections::HashSet<String>,
|
||||
@@ -4621,9 +4973,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 +5057,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 +5107,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.
|
||||
|
||||
+101
-11
@@ -1,13 +1,14 @@
|
||||
//! 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<()> {
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||||
match &f.kind {
|
||||
FeatureKind::Patch { sketch, profiles, body, edges } => {
|
||||
FeatureKind::Patch { sketch, profiles, body, edges, crown } => {
|
||||
let mut made = Vec::new();
|
||||
if let Some(sk) = sketch {
|
||||
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
|
||||
@@ -19,7 +20,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
if !edges.is_empty() {
|
||||
let i = body_at(st, body, edges)?;
|
||||
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("body".into()))?;
|
||||
made.push(kernel::patch_edges(&b.body, edges)?);
|
||||
made.push(kernel::patch_edges_crowned(&b.body, edges, *crown)?);
|
||||
}
|
||||
if made.is_empty() {
|
||||
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
|
||||
@@ -77,16 +78,55 @@ 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);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceBlend { from, to, continuity, bulge, align, stitch } => {
|
||||
let a = st.body(&from.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", from.body)))?.body.clone();
|
||||
let b = st.body(&to.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", to.body)))?.body.clone();
|
||||
if from.edges.is_empty() || to.edges.is_empty() {
|
||||
return Err(DocError::Invalid("a blend needs a chain of edges at each end".into()));
|
||||
}
|
||||
let (made, gap) = kernel::blend_between(&a, &from.edges, &b, &to.edges, *continuity, *bulge, *align, BLEND_STATIONS, *stitch)?;
|
||||
if gap > 1e-9 {
|
||||
*warning = Some(format!("the blend's boundary lies up to {gap:.4} mm from the edges it was built on"));
|
||||
}
|
||||
let name = new_name(st, f, 0);
|
||||
// A stitched blend takes the place of both bodies it joins; otherwise it is a new
|
||||
// surface body of its own.
|
||||
if *stitch {
|
||||
let gone = [from.body.clone(), to.body.clone()];
|
||||
st.bodies.retain(|x| !gone.contains(&x.name));
|
||||
}
|
||||
st.bodies.push(ModelBody::new(name, made, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
@@ -156,7 +196,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 +216,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 +275,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())),
|
||||
}
|
||||
}
|
||||
@@ -262,6 +349,9 @@ fn sheet_chains(ss: &SolvedSketch, profiles: Option<&ProfileSel>, curves: &[Stri
|
||||
|
||||
/// Most sketch curves one feature chains.
|
||||
const MAX_CHAIN_CURVES: usize = 1000;
|
||||
/// Stations a blend samples each of its two boundaries at: enough that the boundary it fits is
|
||||
/// within a small part of a thousandth of the chain's length of the edges it was built on.
|
||||
const BLEND_STATIONS: usize = 41;
|
||||
|
||||
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
|
||||
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
|
||||
|
||||
+38
-1
@@ -237,6 +237,8 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
let at = |i: usize| cs.get(i).map(|x| x.0).unwrap_or(s.len());
|
||||
let mut i = 0;
|
||||
let mut out = Vec::new();
|
||||
// Parentheses open at this point: a comma outside them can only be a decimal comma.
|
||||
let mut depth = 0usize;
|
||||
while let Some(&(_, c)) = cs.get(i) {
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
@@ -245,6 +247,17 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit() || *c == '.') {
|
||||
i += 1;
|
||||
}
|
||||
// A decimal comma (`2,5 mm`, as typed with a numeric keypad in many locales, #1347):
|
||||
// a comma between digits, in a number with no point, outside any parentheses (inside
|
||||
// them a comma separates a function's arguments: `max(1,2)`).
|
||||
let digit = |k: usize| cs.get(k).is_some_and(|(_, c)| c.is_ascii_digit());
|
||||
let has_point = cs.get(st..i).is_some_and(|ds| ds.iter().any(|(_, c)| *c == '.'));
|
||||
if depth == 0 && !has_point && digit(i.wrapping_sub(1)) && cs.get(i).is_some_and(|(_, c)| *c == ',') && digit(i + 1) {
|
||||
i += 1;
|
||||
while digit(i) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
// Exponent: 1e-3 (but not `2em`).
|
||||
if cs.get(i).is_some_and(|(_, c)| *c == 'e' || *c == 'E') {
|
||||
let sign = cs.get(i + 1).is_some_and(|(_, c)| *c == '-' || *c == '+');
|
||||
@@ -257,7 +270,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
}
|
||||
}
|
||||
let t = s.get(at(st)..at(i)).unwrap_or_default();
|
||||
let v: f64 = t.parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?;
|
||||
let v: f64 = t.replace(',', ".").parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?;
|
||||
out.push((Tok::Num(v), at(st), at(i)));
|
||||
} else if c.is_alphabetic() || c == '_' || c == 'µ' {
|
||||
let st = i;
|
||||
@@ -282,6 +295,11 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
out.push((Tok::Op(','), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if "+-*/^(),%".contains(c) {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
out.push((Tok::Op(c), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else {
|
||||
@@ -819,6 +837,25 @@ mod tests {
|
||||
ParamDef { name: n.into(), expr: e.into(), unit: u.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_decimal_comma_outside_parentheses() {
|
||||
// #1347: a comma between digits is a decimal comma where it cannot separate arguments.
|
||||
assert_eq!(ev("2,5", Kind::Unitless).unwrap(), 2.5);
|
||||
assert_eq!(ev("2,5 mm", Kind::Length).unwrap(), 2.5);
|
||||
assert_eq!(ev("12,75mm + 0,25", Kind::Length).unwrap(), 13.0);
|
||||
assert_eq!(ev("width / 2,5", Kind::Length).unwrap(), 16.0);
|
||||
assert!((ev("1,5e3 mm", Kind::Length).unwrap() - 1500.0).abs() < 1e-9);
|
||||
assert!((ev("22,5 deg", Kind::Angle).unwrap() - 22.5f64.to_radians()).abs() < 1e-12);
|
||||
// Inside parentheses a comma still separates a function's arguments.
|
||||
assert_eq!(ev("max(1,2)", Kind::Unitless).unwrap(), 2.0);
|
||||
assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0);
|
||||
assert_eq!(ev("(2,5)", Kind::Unitless).ok(), None, "a comma inside parentheses is not a decimal comma");
|
||||
// Not between two digits, or a second comma: still an error.
|
||||
for bad in ["2,", ",5", "2 ,5", "2, 5", "1,5,3", "1.5,3"] {
|
||||
assert!(ev(bad, Kind::Unitless).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_and_units() {
|
||||
assert_eq!(ev("10", Kind::Length).unwrap(), 10.0);
|
||||
|
||||
@@ -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,270 @@
|
||||
//! 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_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
|
||||
("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,376 @@
|
||||
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_blend
|
||||
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,177 @@
|
||||
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_blend
|
||||
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
|
||||
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,199 @@
|
||||
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_blend
|
||||
remap_bodies: from.body
|
||||
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
|
||||
+228
-3
@@ -138,13 +138,97 @@
|
||||
//! 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).
|
||||
//! - 22: an Import (STEP) feature can list `skip`: indices of the file's bodies it does not
|
||||
//! build, because they are later instances of a part the file places more than once and the
|
||||
//! other occurrences of that part's component show them (#1386: an imported file becomes a
|
||||
//! component, an assembly in it nested components). Version 21 designs need no
|
||||
//! transformation: an Import without it builds every body, as it always did, and designs
|
||||
//! whose imports sit loose in a component are left as they are. The version stamp makes
|
||||
//! 0.12.0 (which reads up to 21) ask for an update instead of building those instances again
|
||||
//! on top of their occurrences.
|
||||
//! Also under 22, a number in an expression can be written with a decimal comma outside
|
||||
//! parentheses (`2,5 mm` is `2.5 mm`, #1347; inside a function's parentheses the comma still
|
||||
//! separates its arguments). Nothing new is saved, but an expression typed that way is saved as
|
||||
//! typed; 0.12.0 asks for an update instead of failing to evaluate it.
|
||||
//! Also under 22, a design can hold a `SurfaceBlend` (Blend Surface: `from`/`to` edge chains of
|
||||
//! two bodies, optional `continuity`, `bulge`, `align`, `stitch`), and a `Patch` can carry a
|
||||
//! `crown` (0 by default, and then not saved: a bump that vanishes on the loop, so the face
|
||||
//! still meets the picked edges). Version 21 designs need no transformation; 0.12.0 asks for an
|
||||
//! update instead of meeting a feature it can't read or dropping a patch's crown.
|
||||
//! Also under 22, a sketch text's link source can say that its place, height or angle are
|
||||
//! expressions (`exprs`: `x`, `y`, `height`, `angle`, each an expression over the design's
|
||||
//! parameters); the source's `at`, `height` and `angle` then hold the numbers they last came
|
||||
//! to. A text without `exprs` is set as it always was; 0.12.0 asks for an update instead of
|
||||
//! dropping the expressions and keeping the text at its last numbers.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 19;
|
||||
pub const FORMAT: u32 = 22;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solidcraft/{n}")
|
||||
@@ -161,7 +245,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; 21] = [
|
||||
|_| Ok(()),
|
||||
v2_to_v3,
|
||||
v3_to_v4,
|
||||
@@ -180,6 +264,9 @@ const JSON_STEPS: [JsonStep; 18] = [
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
];
|
||||
|
||||
/// Stored face and edge names were worked out from geometry before format 14; they get the
|
||||
@@ -378,7 +465,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 +542,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 +570,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 +601,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 +934,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 +1171,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 +1736,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,
|
||||
|
||||
+837
-47
@@ -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,694 @@ 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
|
||||
}
|
||||
|
||||
/// Each value's limits, or none.
|
||||
fn limit_bounds(tree: &Tree, vars: &[(u64, usize)]) -> 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()
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
/// How far `nearby_pose` moves each value: radians for an angle, a share of the loops' size for a
|
||||
/// distance. Far enough for a part's motion that vanishes at a dead point to show in a rank, near
|
||||
/// enough to stay on the mechanism's branch.
|
||||
const NUDGE: f64 = 1e-3;
|
||||
|
||||
/// A pose of the loops near `vals`: each value they move nudged by an amount of its own within
|
||||
/// its limits (so the nudge points in no special direction), then each group of loops closed
|
||||
/// again. A group that can't close again, or only further away than a few nudges (on another
|
||||
/// branch), keeps its values. It has a work budget of its own.
|
||||
fn nearby_pose(tree: &Tree, groups: &[Group], asm: &Assembly, linked: &BTreeSet<(u64, usize)>, vals: &Values) -> Values {
|
||||
let mut near = vals.clone();
|
||||
let mut spent = 0usize;
|
||||
for (closing, around, _) in groups {
|
||||
let vars = unknowns(tree, around, asm, linked, &Held::new());
|
||||
if vars.is_empty() || vars.len() > MAX_LOOP_VALUES {
|
||||
continue;
|
||||
}
|
||||
let scale = loop_scale(tree, around, vals);
|
||||
let step: Vec<f64> =
|
||||
vars.iter().map(|(id, i)| NUDGE * if tree.joints.get(id).is_some_and(|j| j.kind.is_angle(*i)) { 1.0 } else { scale }).collect();
|
||||
let bounds = limit_bounds(tree, &vars);
|
||||
let x0: Vec<f64> = vars.iter().map(|(id, i)| near.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
|
||||
let mut x: Vec<f64> = x0
|
||||
.iter()
|
||||
.zip(step.iter().zip(&bounds))
|
||||
.enumerate()
|
||||
.map(|(k, (v, (d, (lo, hi))))| {
|
||||
// 0.5 to 1.5 nudges, alternately up and down.
|
||||
let w = 0.5 + (k as f64 * 0.618_034).fract();
|
||||
(v + d * if k % 2 == 0 { w } else { -w }).max(*lo).min(*hi)
|
||||
})
|
||||
.collect();
|
||||
let mut work = near.clone();
|
||||
let mut gaps = |x: &[f64]| -> Option<Vec<f64>> {
|
||||
spent += 1;
|
||||
if spent > MAX_LOOP_WORK {
|
||||
return None;
|
||||
}
|
||||
for ((id, i), v) in vars.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(closing.len() * 9);
|
||||
closure_gaps(tree, closing, &work, scale, &mut out)?;
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
};
|
||||
if least_squares(&mut gaps, &mut x, &bounds, (1e-10 * scale).min(1e-7))
|
||||
&& x.iter().zip(&x0).zip(&step).all(|((a, b), d)| (a - b).abs() <= 4.0 * d)
|
||||
&& gaps(&x).is_some()
|
||||
{
|
||||
near = work;
|
||||
}
|
||||
}
|
||||
near
|
||||
}
|
||||
|
||||
/// 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. It is counted at
|
||||
/// the pose and at a pose nearby, and the larger count stands: at a dead point of its loop (an
|
||||
/// actuator in line with the path of its tip pin) a part's motion vanishes to first order, so the
|
||||
/// pose alone counts 0. 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);
|
||||
// A pose nearby, when one differs.
|
||||
let near = (!candidates.is_empty()).then(|| nearby_pose(tree, &groups, asm, &linked, vals)).filter(|n| n != vals);
|
||||
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 (n, gaps) = (values.len(), closers.len() * 9);
|
||||
// How many ways the occurrence moves at a pose, with the loops kept closed.
|
||||
let mut free_at = |at: &Values| -> Option<usize> {
|
||||
let mut work = at.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)| at.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
|
||||
let jac = jacobian(&mut f, &x0, gaps + 9)?;
|
||||
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()
|
||||
};
|
||||
Some(rank(gaps + 9).saturating_sub(rank(gaps)))
|
||||
};
|
||||
let here = free_at(vals);
|
||||
let there = near.as_ref().and_then(&mut free_at);
|
||||
let Some(free) = here.max(there) else { continue };
|
||||
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 = limit_bounds(tree, &vars);
|
||||
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 +1122,24 @@ 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();
|
||||
// The joints at each occurrence, in the joints' order (a large assembly is not walked joint by
|
||||
// joint for every occurrence).
|
||||
let mut at: BTreeMap<u64, Vec<&Joint>> = BTreeMap::new();
|
||||
for j in joints.iter().copied() {
|
||||
at.entry(j.a.occurrence).or_default().push(j);
|
||||
if j.b.occurrence != j.a.occurrence {
|
||||
at.entry(j.b.occurrence).or_default().push(j);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
for j in at.get(&occ).map(Vec::as_slice).unwrap_or_default() {
|
||||
if used.contains(&j.id) || !tree.frames.contains_key(&j.id) {
|
||||
continue;
|
||||
}
|
||||
let (other, from_a) = if j.a.occurrence == occ {
|
||||
@@ -439,29 +1152,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 +1206,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 +1264,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 +1318,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}");
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -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,
|
||||
revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance, world_state,
|
||||
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, import_body_names, 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
|
||||
|
||||
+80
-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}`")));
|
||||
@@ -839,6 +584,11 @@ impl Document {
|
||||
out.push(ParamUser::Dimension { feature: f.id, name: f.name.clone(), constraint: c.id.clone() });
|
||||
}
|
||||
}
|
||||
for (what, e) in sketch.text_exprs() {
|
||||
if expr::references(e).iter().any(|r| r == name) {
|
||||
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: what.to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -917,6 +667,13 @@ impl Document {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for e in sketch.text_exprs_mut() {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.favorites.remove(old) {
|
||||
@@ -972,6 +729,7 @@ impl Document {
|
||||
let mut names: BTreeSet<String> = f.param_names.iter().filter(|n| !n.is_empty()).cloned().collect();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
|
||||
}
|
||||
out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n)));
|
||||
}
|
||||
@@ -999,6 +757,7 @@ impl Document {
|
||||
hit |= f.kind.expressions().iter().any(|e| expr::references(e).iter().any(|r| affected.contains(r)));
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
hit |= sketch.constraints.iter().any(|c| c.param.as_ref().is_some_and(|p| affected.contains(p)));
|
||||
hit |= sketch.text_exprs().iter().any(|(_, e)| expr::references(e).iter().any(|r| affected.contains(r)));
|
||||
}
|
||||
if hit {
|
||||
out.push(f.id);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::{DocError, Result};
|
||||
|
||||
@@ -116,6 +116,10 @@ pub struct Validation {
|
||||
pub property_kinds: Vec<String>,
|
||||
pub isotropic_moduli: Option<IsotropicModuli>,
|
||||
pub unspecified_conditions: Vec<String>,
|
||||
/// Property kind -> absent condition fields under the existing family policy.
|
||||
/// Empty when reading an older report that did not include this detail.
|
||||
#[serde(default)]
|
||||
pub missing_condition_fields: BTreeMap<String, Vec<String>>,
|
||||
pub source_review_required: Vec<String>,
|
||||
pub analysis_solver_available: bool,
|
||||
pub rendering_properties_available: bool,
|
||||
@@ -256,6 +260,7 @@ impl MaterialCard {
|
||||
let mut kinds = BTreeSet::new();
|
||||
let mut used_sources = BTreeSet::new();
|
||||
let mut unspecified = Vec::new();
|
||||
let mut missing_condition_fields = BTreeMap::new();
|
||||
let mut isotropic_moduli = None;
|
||||
for property in &self.properties {
|
||||
let kind = property.data.kind();
|
||||
@@ -279,12 +284,20 @@ impl MaterialCard {
|
||||
if let Some(state) = &conditions.state {
|
||||
text(state, 1024, "conditions.state")?;
|
||||
}
|
||||
if conditions.temperature_k.is_none()
|
||||
|| conditions.state.is_none()
|
||||
|| (self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none())
|
||||
|| (self.family == Family::Polymer && conditions.strain_rate_per_s.is_none())
|
||||
{
|
||||
let mut missing = Vec::new();
|
||||
for (absent, field) in [
|
||||
(conditions.temperature_k.is_none(), "temperature_k"),
|
||||
(conditions.state.is_none(), "state"),
|
||||
(self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none(), "moisture_dry_basis_kg_kg"),
|
||||
(self.family == Family::Polymer && conditions.strain_rate_per_s.is_none(), "strain_rate_per_s"),
|
||||
] {
|
||||
if absent {
|
||||
missing.push(field.to_string());
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
unspecified.push(kind.to_string());
|
||||
missing_condition_fields.insert(kind.to_string(), missing);
|
||||
}
|
||||
if let Some(moduli) = property.data.validate()? {
|
||||
isotropic_moduli = Some(moduli);
|
||||
@@ -295,6 +308,7 @@ impl MaterialCard {
|
||||
property_kinds: kinds.into_iter().collect(),
|
||||
isotropic_moduli,
|
||||
unspecified_conditions: unspecified,
|
||||
missing_condition_fields,
|
||||
source_review_required: used_sources.into_iter().collect(),
|
||||
analysis_solver_available: false,
|
||||
rendering_properties_available: false,
|
||||
@@ -305,6 +319,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 +365,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();
|
||||
@@ -70,6 +132,58 @@ fn conditions_missing_values_and_source_claims_remain_explicit() {
|
||||
assert_eq!(serde_json::from_value::<MaterialCard>(serde_json::to_value(&parsed).unwrap()).unwrap(), parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_condition_fields_follow_family_policy_without_inventing_values() {
|
||||
for (family, extra) in
|
||||
[("wood", Some("moisture_dry_basis_kg_kg")), ("polymer", Some("strain_rate_per_s")), ("glass", None), ("metal", None), ("other", None)]
|
||||
{
|
||||
let mut value = card(json!({"kind":"density","kg_m3":600.}));
|
||||
value["family"] = json!(family);
|
||||
let mut expected = vec!["temperature_k", "state"];
|
||||
expected.extend(extra);
|
||||
let result = validate_card(&value).unwrap();
|
||||
assert_eq!(result.missing_condition_fields, BTreeMap::from([("density".into(), expected.iter().map(|s| (*s).into()).collect())]));
|
||||
assert_eq!(result.unspecified_conditions, vec!["density"]);
|
||||
|
||||
// Explicit zero dry-basis moisture is known; no wet/dry conversion or default is inferred.
|
||||
value["properties"][0]["conditions"] =
|
||||
json!({"temperature_k":293.15,"state":"synthetic","moisture_dry_basis_kg_kg":0.,"strain_rate_per_s":1.});
|
||||
assert!(validate_card(&value).unwrap().missing_condition_fields.is_empty());
|
||||
for field in expected {
|
||||
let mut partial = value.clone();
|
||||
partial["properties"][0]["conditions"].as_object_mut().unwrap().remove(field);
|
||||
let result = validate_card(&partial).unwrap();
|
||||
assert_eq!(result.missing_condition_fields, BTreeMap::from([("density".into(), vec![field.into()])]));
|
||||
assert_eq!(result.unspecified_conditions, vec!["density"]);
|
||||
}
|
||||
value["properties"] = json!([]);
|
||||
let result = validate_card(&value).unwrap();
|
||||
assert!(result.missing_condition_fields.is_empty() && result.unspecified_conditions.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_condition_fields_are_per_property_and_reports_remain_backward_readable() {
|
||||
let mut value = card(json!({"kind":"density","kg_m3":600.}));
|
||||
value["properties"][0]["conditions"] = json!({"temperature_k":293.15,"state":"synthetic"});
|
||||
value["properties"].as_array_mut().unwrap().push(json!({"data":isotropic(12e9,0.25),"source_id":"test",
|
||||
"conditions":{"state":"synthetic","moisture_dry_basis_kg_kg":1.5}}));
|
||||
let before = value.clone();
|
||||
let result = validate_card(&value).unwrap();
|
||||
let expected = json!({"density":["moisture_dry_basis_kg_kg"],"elasticity":["temperature_k"]});
|
||||
let serialized = serde_json::to_value(&result).unwrap();
|
||||
assert_eq!(serialized["missing_condition_fields"], expected);
|
||||
assert_eq!(result.unspecified_conditions, vec!["density", "elasticity"]);
|
||||
assert_eq!(value, before);
|
||||
assert_eq!(serde_json::from_value::<Validation>(serialized.clone()).unwrap(), result);
|
||||
let mut old = serialized;
|
||||
old.as_object_mut().unwrap().remove("missing_condition_fields");
|
||||
let old: Validation = serde_json::from_value(old).unwrap();
|
||||
assert!(old.missing_condition_fields.is_empty());
|
||||
assert_eq!(old.unspecified_conditions, result.unspecified_conditions);
|
||||
assert_eq!(old.source_review_required, result.source_review_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thermal_properties_accept_negative_expansion_but_not_negative_conductivity() {
|
||||
assert!(validate_card(&card(json!({"kind":"thermal_expansion","per_k":[-1e-6,0.,2e-6],"axes":"material123"}))).is_ok());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ fn resolve_uncached(
|
||||
LinkSource::SketchAxis { name } => {
|
||||
vec![solidcraft_sketch::sketch_axis_geom(name).ok_or_else(|| DocError::Unknown(format!("sketch axis `{name}`")))?]
|
||||
}
|
||||
LinkSource::Text { text, at, height, angle, font, style } if style.path.is_none() => {
|
||||
LinkSource::Text { text, at, height, angle, font, style, .. } if style.path.is_none() => {
|
||||
solidcraft_sketch::text_geometry_styled(font.as_deref(), text, *at, *height, *angle, style, None)?
|
||||
}
|
||||
// Its path is a curve of its own sketch: set with the sketch (`Sketch::text_source_geometry`).
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! 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();
|
||||
let (width, height) = (u64::from(self.width), u64::from(self.height));
|
||||
if width == 0 || height == 0 {
|
||||
return Err(DocError::Invalid("render output must be at least 1 × 1 pixels".into()));
|
||||
}
|
||||
if let Some((w, h)) = limits.fit(width, height, None)
|
||||
&& (w, h) != (width, height)
|
||||
{
|
||||
return Err(DocError::Invalid(format!(
|
||||
"render output {width} × {height} exceeds the image limits ({} pixels per side, {} pixels in all); \
|
||||
the largest image at this aspect is {w} × {h}",
|
||||
limits.max_dimension, limits.max_pixels
|
||||
)));
|
||||
}
|
||||
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 oversized_output_names_the_limits_and_the_largest_size() {
|
||||
let setup = RenderSetup { width: 4096, height: 4096, ..Default::default() };
|
||||
let message = setup.validate().unwrap_err().to_string();
|
||||
assert!(message.contains("4096 × 4096 exceeds the image limits (4096 pixels per side, 4194304 pixels in all)"), "{message}");
|
||||
assert!(message.contains("the largest image at this aspect is 2048 × 2048"), "{message}");
|
||||
let setup = RenderSetup { width: 7680, height: 4320, ..Default::default() };
|
||||
let message = setup.validate().unwrap_err().to_string();
|
||||
assert!(message.contains("the largest image at this aspect is 27"), "{message}");
|
||||
assert!(RenderSetup { width: 2048, height: 2048, ..Default::default() }.validate().is_ok());
|
||||
}
|
||||
|
||||
#[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,390 @@
|
||||
//! 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 solidcraft_kernel::Continuity {}
|
||||
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, const N: usize> Each<T> for [T; N] {
|
||||
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, const N: usize> Each<T> for Option<[T; N]> {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
|
||||
if let Some(a) = self {
|
||||
for t in a {
|
||||
f(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
|
||||
if let Some(a) = self {
|
||||
for t in a {
|
||||
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,51 @@ 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);
|
||||
}
|
||||
|
||||
/// #1275: an Extrude Cut of a whole face of a rounded-rectangle block with a bottom chamfer. The
|
||||
/// tool's corner cylinders lay on the block's; they are grown off them now, as straight edges
|
||||
/// were, and the cut takes 5 mm off the top.
|
||||
#[test]
|
||||
fn cut_of_a_whole_face_beside_round_corners_after_a_chamfer() {
|
||||
let s = replay(include_str!("../../../examples/regressions/cut_whole_face_after_bottom_chamfer.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());
|
||||
let pi = std::f64::consts::PI;
|
||||
let block = (40.0 * 40.0 - (4.0 - pi) * 8.0 * 8.0) * 15.0;
|
||||
// The chamfer's 2 × 2 triangle swept along the loop (Pappus: its centroid is 2/3 mm in).
|
||||
let chamfer = 2.0 * (4.0 * 24.0 + 2.0 * pi * (8.0 - 2.0 / 3.0));
|
||||
let mesh = b.body.tessellate(1e-4).unwrap();
|
||||
let volume = mesh.measure().volume;
|
||||
assert!((volume - (block - chamfer)).abs() < 0.05, "{volume} expected {}", block - chamfer);
|
||||
let top = mesh.bounds().max.z;
|
||||
assert!((top - 15.0).abs() < 1e-6, "top at {top}");
|
||||
}
|
||||
|
||||
@@ -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,178 @@ 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)"]);
|
||||
// Each import is a component of its own (#1386), shown after the pasted copies: the first
|
||||
// import's bodies stay where they were, the second's and their copies are lifted.
|
||||
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>> = LIFTED.iter().chain(&plain).chain(&LIFTED).map(|c| c.to_vec()).collect();
|
||||
assert_eq!(corners(&mut s), at, "{:?}", body_names(&s));
|
||||
let m = run(&mut s, "inspect.measure", json!({}));
|
||||
let names: Vec<&str> = m["bodies"].as_array().unwrap().iter().filter_map(|b| b["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Aa (copy)", "Bb (copy)", "Cc (copy)", "Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
|
||||
}
|
||||
|
||||
/// 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 +437,99 @@ 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;
|
||||
// A spring on the wrong plate is hundreds of mm³ off. The two plates are measured apart
|
||||
// (one moved 50 mm), which the boolean and the measure each round in their own way:
|
||||
// a few 1e-6 mm³ on 5000.
|
||||
assert!((v("Plate") - v("Plate (copy)")).abs() < 1e-8 * v("Plate"), "each plate carries one spring: {} vs {}", v("Plate"), v("Plate (copy)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Compare Bodies: two versions of one part laid over each other. Both are measured, each
|
||||
//! surface's distance to the other is sampled, and the places where only one of them has
|
||||
//! material are found, measured and, on request, coloured. It works from the surfaces
|
||||
//! ([`solidcraft_geom::compare`]), not from a boolean of the two bodies, so bodies that share
|
||||
//! almost every face compare as well as any.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::compare::{self, CompareOptions, Side};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).params(
|
||||
"a, b: body names (default: the two selected bodies, or the design's only two), in world placement; tolerance?: mm (0.001 to 10, default 0.01): \
|
||||
surfaces closer than this agree; spacing?: mm (default 0.5): the largest piece of a face sampled as one point, coarser for a body that would need \
|
||||
more than max_points? (default 8 million, up to 50 million) samples; max_regions? (default 50); color?: true | {only_in_a?, only_in_b?: \"#rrggbb\"} \
|
||||
(colours the faces that hold a region, red where only a has material and green where only b has: the faces mostly beyond the tolerance, else the \
|
||||
one with the largest share; an undo step) → a, b: volume_mm3, area_mm2, center_of_mass, bbox; difference: b − a; \
|
||||
deviation: a_to_b, b_to_a (max_mm, mean_mm, at, area_beyond_mm2); regions, largest first: side (only_in_a | only_in_b), volume_mm3 (integrated along \
|
||||
lines through the region: resolution), area_mm2, max_deviation_mm, at, center, bbox, faces_a, faces_b (face indices, as model.faces lists them); \
|
||||
identical; verdict; warnings (a body whose surface does not enclose what it measures, lines left out). Works from the two surfaces, without a boolean of the bodies, so near-identical bodies compare too",
|
||||
)];
|
||||
|
||||
/// Default colours: red for material only `a` has, green for material only `b` has.
|
||||
const ONLY_IN_A: [u8; 3] = [0xe0, 0x31, 0x31];
|
||||
const ONLY_IN_B: [u8; 3] = [0x2f, 0x9e, 0x44];
|
||||
|
||||
fn side_name(s: Side) -> &'static str {
|
||||
match s {
|
||||
Side::OnlyInA => "only_in_a",
|
||||
Side::OnlyInB => "only_in_b",
|
||||
}
|
||||
}
|
||||
|
||||
/// A number parameter in `range`, `default` when absent.
|
||||
fn number(cmd: &str, p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && range.contains(x))
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number from {} to {}", range.start(), range.end()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two bodies: `a` and `b`, else the two selected bodies, else the design's only two.
|
||||
fn pair(s: &Session, st: &solidcraft_doc::ModelState, p: &Value, cmd: &str) -> Result<(String, String)> {
|
||||
let given = |k: &str| -> Result<Option<String>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(n)) => Ok(Some(n.clone())),
|
||||
Some(_) => Err(bad(cmd, format!("`{k}` must be a body name"))),
|
||||
}
|
||||
};
|
||||
let (a, b) = match (given("a")?, given("b")?) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
(None, None) => {
|
||||
let selected: Vec<String> =
|
||||
s.selection.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
|
||||
let all: Vec<String> = st.bodies.iter().map(|b| b.name.clone()).collect();
|
||||
match (selected.as_slice(), all.as_slice()) {
|
||||
([a, b], _) | (_, [a, b]) => (a.clone(), b.clone()),
|
||||
_ => return Err(bad(cmd, "give `a` and `b` (or select two bodies)")),
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(cmd, "give both `a` and `b`")),
|
||||
};
|
||||
for n in [&a, &b] {
|
||||
if st.body(n).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{n}`")));
|
||||
}
|
||||
}
|
||||
if a == b {
|
||||
return Err(bad(cmd, "`a` and `b` must be two different bodies"));
|
||||
}
|
||||
Ok((a, b))
|
||||
}
|
||||
|
||||
fn colours(cmd: &str, p: &Value) -> Result<Option<[[u8; 3]; 2]>> {
|
||||
match p.get("color") {
|
||||
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
|
||||
Some(Value::Bool(true)) => Ok(Some([ONLY_IN_A, ONLY_IN_B])),
|
||||
Some(Value::Object(o)) => {
|
||||
let pick = |k: &str, d: [u8; 3]| match o.get(k) {
|
||||
None | Some(Value::Null) => Ok(d),
|
||||
Some(v) => {
|
||||
super::inspect::color_param(v).ok_or_else(|| bad(cmd, format!("`color.{k}` must be \"#rrggbb\" or [r, g, b] with values 0–255")))
|
||||
}
|
||||
};
|
||||
Ok(Some([pick("only_in_a", ONLY_IN_A)?, pick("only_in_b", ONLY_IN_B)?]))
|
||||
}
|
||||
Some(_) => Err(bad(cmd, "`color` must be true or {only_in_a, only_in_b}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn v3(v: &Value) -> Option<Vec3> {
|
||||
crate::params::vec3(v)
|
||||
}
|
||||
|
||||
/// b − a of the two bodies' measures.
|
||||
fn difference(ma: &Value, mb: &Value) -> Value {
|
||||
let f = |m: &Value, k: &str| m.get(k).and_then(Value::as_f64);
|
||||
let sub = |x: Option<f64>, y: Option<f64>| x.zip(y).map(|(x, y)| y - x);
|
||||
let (va, vb) = (f(ma, "volume_mm3"), f(mb, "volume_mm3"));
|
||||
let com = v3(&ma["center_of_mass"]).zip(v3(&mb["center_of_mass"])).map(|(a, b)| b - a);
|
||||
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| b - a);
|
||||
json!({
|
||||
"volume_mm3": sub(va, vb),
|
||||
"volume_percent": va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
|
||||
"area_mm2": sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
|
||||
"center_of_mass": com,
|
||||
"center_of_mass_distance_mm": com.map(Vec3::len),
|
||||
"bbox_min": corner("min"),
|
||||
"bbox_max": corner("max"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The volume a closed mesh encloses (divergence theorem, about its box's centre).
|
||||
fn enclosed_volume(m: &solidcraft_geom::Mesh) -> f64 {
|
||||
let o = m.bounds().center();
|
||||
m.triangles.iter().filter_map(|t| m.tri(t)).map(|[a, b, c]| (a - o).dot((b - o).cross(c - o)) / 6.0).sum()
|
||||
}
|
||||
|
||||
/// What makes the comparison less certain: a body whose surface does not enclose what it
|
||||
/// measures (open, or faces that failed), lines left out of region volumes.
|
||||
fn warnings(bodies: [(&str, &Value, &solidcraft_geom::Mesh); 2], chord: f64, c: &compare::Comparison) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for (name, measure, mesh) in bodies {
|
||||
let (Some(v), area) = (measure["volume_mm3"].as_f64(), measure["area_mm2"].as_f64().unwrap_or(0.0)) else { continue };
|
||||
let enclosed = enclosed_volume(mesh);
|
||||
// A mesh within `chord` of the surface encloses the volume to within chord × area.
|
||||
if !((enclosed - v).abs() <= chord * area.abs() + 1e-6 * v.abs()) {
|
||||
out.push(format!(
|
||||
"`{name}`: its surface encloses {enclosed:.3} mm³ but the body measures {v:.3} mm³: it is open or has faces that failed, so its measures and the regions near the gap are unreliable"
|
||||
));
|
||||
}
|
||||
}
|
||||
let skipped: usize = c.regions.iter().map(|r| r.lines.skipped).sum();
|
||||
if skipped > 0 {
|
||||
out.push(format!("{skipped} line(s) through the regions crossed a gap in a surface and were left out of their volumes"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn deviation_json(d: &compare::Deviation) -> Value {
|
||||
json!({"max_mm": d.max, "mean_mm": d.mean, "at": d.at, "area_beyond_mm2": d.beyond_area, "area_mm2": d.area, "points": d.points})
|
||||
}
|
||||
|
||||
fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.compare";
|
||||
let tolerance = number(cmd, p, "tolerance", 0.01, 0.001..=10.0)?;
|
||||
let spacing = number(cmd, p, "spacing", 0.5, 0.01..=1000.0)?;
|
||||
let max_points = number(cmd, p, "max_points", 8e6, 1e4..=5e7)? as usize;
|
||||
let max_regions = number(cmd, p, "max_regions", 50.0, 1.0..=1000.0)? as usize;
|
||||
let colors = colours(cmd, p)?;
|
||||
let st = s.world_state();
|
||||
let (na, nb) = pair(s, &st, p, cmd)?;
|
||||
let (Some(ba), Some(bb)) = (st.body(&na), st.body(&nb)) else { return Err(bad(cmd, "no such body")) };
|
||||
// Meshed within a quarter of the tolerance (the kernel goes no finer than a millionth of a
|
||||
// body's size), so two meshes of one surface agree well within it.
|
||||
let chord = tolerance / 4.0;
|
||||
let mut done = solidcraft_kernel::map_on_threads(&[ba, bb], 2, |b| (super::inspect::measure_json(b), b.body.tessellate_with(chord, true)))
|
||||
.into_iter()
|
||||
.zip([&na, &nb]);
|
||||
let mut next = || -> Result<(Value, solidcraft_geom::Mesh)> {
|
||||
let Some((r, name)) = done.next() else { return Err(bad(cmd, "no such body")) };
|
||||
let (m, mesh) = r.ok_or_else(|| bad(cmd, format!("`{name}`: measuring it failed")))?;
|
||||
let mesh = mesh.map_err(|e| bad(cmd, format!("`{name}`: {e}")))?;
|
||||
if mesh.triangles.is_empty() {
|
||||
return Err(bad(cmd, format!("`{name}` has no surface")));
|
||||
}
|
||||
Ok((m, mesh))
|
||||
};
|
||||
let ((measure_a, mesh_a), (measure_b, mesh_b)) = (next()?, next()?);
|
||||
let options = CompareOptions { tolerance, spacing, max_points, max_regions, ..CompareOptions::default() };
|
||||
let each = |n: usize, job: &(dyn Fn(usize) + Sync)| {
|
||||
let jobs: Vec<usize> = (0..n).collect();
|
||||
solidcraft_kernel::for_each_on_threads(&jobs, 16, |i| job(*i));
|
||||
};
|
||||
let c = compare::compare(&mesh_a, &mesh_b, &options, &each);
|
||||
let axis = |k: usize| ["X", "Y", "Z"].get(k).copied().unwrap_or("?");
|
||||
let regions: Vec<Value> = c
|
||||
.regions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| {
|
||||
json!({
|
||||
"index": i + 1,
|
||||
"side": side_name(r.side),
|
||||
"material_of": if r.side == Side::OnlyInA { &na } else { &nb },
|
||||
"volume_mm3": r.volume,
|
||||
"area_mm2": r.area_a + r.area_b,
|
||||
"area_a_mm2": r.area_a,
|
||||
"area_b_mm2": r.area_b,
|
||||
"max_deviation_mm": r.max_deviation,
|
||||
"at": r.at,
|
||||
"center": r.center,
|
||||
"bbox": {"min": r.bbox.min, "max": r.bbox.max},
|
||||
"faces_a": r.faces_a.iter().map(|x| x.0).collect::<Vec<u32>>(),
|
||||
"faces_b": r.faces_b.iter().map(|x| x.0).collect::<Vec<u32>>(),
|
||||
"resolution": {"along": axis(r.lines.axis), "line_spacing_mm": r.lines.spacing, "lines": r.lines.count, "skipped_lines": r.lines.skipped},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let total = |side: Side| {
|
||||
let rs: Vec<&compare::Region> = c.regions.iter().filter(|r| r.side == side).collect();
|
||||
// `+ 0.0`: an empty sum is -0.0.
|
||||
(rs.len(), rs.iter().map(|r| r.volume).filter(|v| v.is_finite()).sum::<f64>() + 0.0)
|
||||
};
|
||||
let ((ca, va), (cb, vb)) = (total(Side::OnlyInA), total(Side::OnlyInB));
|
||||
let worst = c.a_to_b.max.max(c.b_to_a.max);
|
||||
let identical = c.regions.is_empty() && c.unlisted == 0;
|
||||
let warnings = warnings([(na.as_str(), &measure_a, &mesh_a), (nb.as_str(), &measure_b, &mesh_b)], chord, &c);
|
||||
let mut verdict = if identical {
|
||||
format!("identical within {tolerance} mm (largest deviation {worst:.4} mm)")
|
||||
} else {
|
||||
let more = if c.unlisted > 0 { format!(" (and {} more, not measured)", c.unlisted) } else { String::new() };
|
||||
format!(
|
||||
"{} {} by more than {tolerance} mm: {ca} with material only in `{na}` ({va:.3} mm³), {cb} only in `{nb}` ({vb:.3} mm³){more}",
|
||||
c.regions.len(),
|
||||
if c.regions.len() == 1 { "region differs" } else { "regions differ" }
|
||||
)
|
||||
};
|
||||
if !warnings.is_empty() {
|
||||
verdict += &format!(" ({} warning{})", warnings.len(), if warnings.len() == 1 { "" } else { "s" });
|
||||
}
|
||||
let mut out = json!({
|
||||
"a": measure_a,
|
||||
"b": measure_b,
|
||||
"difference": difference(&measure_a, &measure_b),
|
||||
"tolerance_mm": tolerance,
|
||||
"deviation": {"a_to_b": deviation_json(&c.a_to_b), "b_to_a": deviation_json(&c.b_to_a), "max_mm": worst},
|
||||
"identical": identical,
|
||||
"verdict": verdict,
|
||||
"regions": regions,
|
||||
"only_in_a": {"regions": ca, "volume_mm3": va},
|
||||
"only_in_b": {"regions": cb, "volume_mm3": vb},
|
||||
"unlisted_regions": c.unlisted,
|
||||
"warnings": warnings,
|
||||
"sampling": {"spacing_mm": c.spacing, "chord_mm": chord, "triangles_a": mesh_a.triangles.len(), "triangles_b": mesh_b.triangles.len()},
|
||||
});
|
||||
if let Some(colors) = colors {
|
||||
out["colored_faces"] = json!(color_regions(s, &c, [&na, &nb], colors)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A face is coloured for a region when at least this share of it deviates there.
|
||||
const MOSTLY: f64 = 0.5;
|
||||
|
||||
/// The faces to colour for a region, as (body: 0 = a, 1 = b; face): those mostly beyond the
|
||||
/// tolerance (a small difference on a large face would colour all of it), or, when it has none,
|
||||
/// the one with the largest share.
|
||||
fn faces_to_color(c: &compare::Comparison, r: &compare::Region) -> Vec<(usize, u32)> {
|
||||
let share = |m: usize, &(f, beyond): &(u32, f64)| {
|
||||
let all = c.face_area.get(m).and_then(|x| x.get(&f)).copied().unwrap_or(0.0);
|
||||
(m, f, if all > 0.0 { beyond / all } else { 0.0 })
|
||||
};
|
||||
let all: Vec<(usize, u32, f64)> = r.faces_a.iter().map(|x| share(0, x)).chain(r.faces_b.iter().map(|x| share(1, x))).collect();
|
||||
let mostly: Vec<(usize, u32)> = all.iter().filter(|x| x.2 >= MOSTLY).map(|x| (x.0, x.1)).collect();
|
||||
if !mostly.is_empty() {
|
||||
return mostly;
|
||||
}
|
||||
all.iter().max_by(|x, y| x.2.total_cmp(&y.2)).map(|x| vec![(x.0, x.1)]).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Give the faces holding each region the colour of its side (a face holding several takes the
|
||||
/// largest one's); returns how many face appearances were set.
|
||||
fn color_regions(s: &mut Session, c: &compare::Comparison, names: [&String; 2], colors: [[u8; 3]; 2]) -> Result<usize> {
|
||||
use solidcraft_doc::appearance::{FaceLook, Look};
|
||||
let st = s.model.state();
|
||||
// Where each face of the two bodies is, as the design file names and finds it. A body placed
|
||||
// by an occurrence is coloured where it is defined, if at all.
|
||||
let found: Vec<_> =
|
||||
names.iter().map(|n| st.body(n).map(|b| (solidcraft_doc::naming::face_spots(b), solidcraft_doc::naming::face_names(b)))).collect();
|
||||
let mut picks: Vec<(String, Vec3, Option<String>, Look)> = Vec::new();
|
||||
// Smallest region first, so the largest one's colour is the one that stays.
|
||||
for r in c.regions.iter().rev() {
|
||||
let colour = if r.side == Side::OnlyInA { colors[0] } else { colors[1] };
|
||||
let mut look = Look::custom(colour, 1.0);
|
||||
look.name = if r.side == Side::OnlyInA { "Only in a".into() } else { "Only in b".into() };
|
||||
for (m, f) in faces_to_color(c, r) {
|
||||
let (Some(Some((spots, face_names))), Some(name)) = (found.get(m), names.get(m)) else { continue };
|
||||
let Some(spot) = spots.get(f as usize).copied().flatten() else { continue };
|
||||
picks.retain(|(b, pt, _, _)| !(b == *name && pt.dist(spot.point) < 1e-9));
|
||||
picks.push(((*name).clone(), spot.point, face_names.get(f as usize).cloned(), look.clone()));
|
||||
}
|
||||
}
|
||||
if picks.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let a = &mut s.doc_mut().appearances;
|
||||
let mut n = 0;
|
||||
for (body, point, name, look) in picks {
|
||||
a.faces.retain(|f| !(f.body == body && f.point.dist(point) < 1e-9));
|
||||
if a.faces.len() >= 100_000 {
|
||||
return Err(bad("inspect.compare", "too many face appearances"));
|
||||
}
|
||||
a.faces.push(FaceLook { body, point, look, name, strict_name: false, certificate: None });
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "compare_tests.rs"]
|
||||
mod tests;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user