mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 03:51:08 +00:00
Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72ad6383ef | |||
| 2cf05388f1 | |||
| 093571cfe4 | |||
| a1b9ca13e3 | |||
| 699f526429 | |||
| 16a5254e97 | |||
| e5613ebfdf | |||
| dfde1e032d | |||
| 10da6b9efc | |||
| 5a5a98ee80 | |||
| 4e2270517a | |||
| 0312808b92 | |||
| 9d45fa6b34 | |||
| e4f11c8df6 | |||
| bbc3aa4301 | |||
| 611d4eb6b8 | |||
| c50f5364c6 | |||
| beb38b44aa | |||
| a49698ee3d | |||
| 919f54a23e | |||
| 1d0e371e64 | |||
| 43565cd33b | |||
| ab9d9ac50f | |||
| 157c0d68d0 | |||
| 38d7ea4198 | |||
| 7c2b2f1559 | |||
| cc1e654690 | |||
| 7af97e3729 | |||
| 138bb0261c | |||
| dbe7dfa4bc | |||
| 1ceab2ed4c | |||
| 743accca7a | |||
| 128692da0c | |||
| 13b4f6e98c | |||
| ac53fc1e6f | |||
| 8d63a9f4cb | |||
| a150822d9b | |||
| a9fd4b1b05 | |||
| face5e2d31 | |||
| efd0e70f7d | |||
| 3f4c6e4571 | |||
| abc39685b1 | |||
| f93913b3d3 | |||
| 765c5fd143 | |||
| 6494f8fe18 | |||
| 1623034973 | |||
| 74004841b4 | |||
| be08517de2 | |||
| 24ed21998b | |||
| 3592031d5e | |||
| b8d9db7440 | |||
| 2b620338df | |||
| a5b6408587 | |||
| 78de687902 | |||
| 2741cd9a8b | |||
| 86f5500914 | |||
| 7804f9938a | |||
| ab03aa5bc5 | |||
| 2e3f27f06e | |||
| bda32419bd | |||
| c242c484a8 | |||
| 5d37cb01d5 | |||
| 7313fd8684 | |||
| 6f340ade69 | |||
| e260437846 | |||
| f54ad0f12f | |||
| 43f21e8fec | |||
| 3e1f1a89f8 | |||
| 82253c6f35 | |||
| f4d937d8a2 | |||
| 75fb613e25 | |||
| ce614044a7 | |||
| 91549d29fb | |||
| b6a5a9166a | |||
| 0063954286 | |||
| 6f6b64d484 | |||
| 52e496d017 | |||
| d557c09c3b | |||
| 7046f7f0e7 | |||
| 16286249ed | |||
| 19e2800472 | |||
| a5f7f5a921 | |||
| a402c5e502 | |||
| cdde7d62b2 | |||
| 3f6b9fe89f | |||
| b4acd83c78 | |||
| f7233c4418 | |||
| 5573ae06fc | |||
| 89a4a96630 | |||
| 34d1ac0376 | |||
| 0e9e175e79 | |||
| f11ad0ce28 | |||
| 9ad96fb81b | |||
| b671265faa | |||
| 832ff941d1 | |||
| 9869997733 | |||
| 745a24bbc8 | |||
| 26340882c0 | |||
| 8e2b8f2ace | |||
| 34e75979b2 | |||
| d5eca6cc78 | |||
| 77c77829e3 | |||
| f01ef3c6aa | |||
| f5a6c8fa51 | |||
| 865575e410 | |||
| 992c93fe5b | |||
| 0343960bd6 | |||
| 1d48707d0c | |||
| 443baa41f0 | |||
| d366b8922a | |||
| 692265980b | |||
| 28fe5fdb43 | |||
| 26c9d1c445 | |||
| f157f5489f | |||
| d1ec8dfd5a | |||
| 08d094db39 | |||
| 5fa6361ff2 | |||
| 1f93b6d39c | |||
| 0477e0ba4e | |||
| ae7176cc86 | |||
| eef9a68809 | |||
| f06875128b | |||
| 9d226b3535 | |||
| 7a1f903216 | |||
| b88673f366 | |||
| 67adf5abb5 | |||
| adbd2adf89 | |||
| 96303873fe | |||
| b925a1af3d | |||
| 05d43d3542 | |||
| c0640cb95e | |||
| 2ff17c0fcb | |||
| ecf57d131a | |||
| 32ad6e0832 | |||
| 13c4a62a71 | |||
| bba1953623 | |||
| 4b119dd471 | |||
| 6224cbf526 | |||
| e89a8f56c1 | |||
| 4cd1a51f48 | |||
| 88f079bd8c | |||
| dbdeeec2e9 | |||
| 1dbb13cad2 | |||
| 0b91ff8a34 | |||
| 1e81e65e7e | |||
| 9227685483 | |||
| 582e6097be | |||
| 22190e703c | |||
| f7b08ab8c4 | |||
| ae0b1edeac | |||
| cf1b0d67cc | |||
| b702ce8c7d | |||
| 60105172b2 | |||
| e2b8bf5a05 | |||
| 9e065aa0d5 | |||
| 2fd7b6f08d | |||
| 59795872f6 | |||
| 37b7b0bd3e | |||
| 7e24f0e35a | |||
| 152efb5774 | |||
| e726f780d1 | |||
| b201c283c7 | |||
| a2ae3ffbe0 | |||
| 96661111f9 |
@@ -2,6 +2,12 @@
|
||||
|
||||
Describe the problem and resulting behavior. Link the issue when applicable.
|
||||
|
||||
Changes a default: no
|
||||
|
||||
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour):
|
||||
Changes a default: yes — <what>, old behaviour kept behind preference <name>
|
||||
Docs-only and bot PRs are exempt. -->
|
||||
|
||||
## Validation
|
||||
|
||||
What did you run, and what were the results? Include expected and measured values for geometry changes.
|
||||
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
- name: Merge-of-main detection self-test
|
||||
run: python3 -I scripts/ci/test_main_merge.py
|
||||
- name: Claim, triage and kernel-paused bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js scripts/pr-checks/*.test.js
|
||||
|
||||
# Fast feedback for a pull request that has not been approved for the full CI yet.
|
||||
quick-compile:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Files generated from the code are rewritten here, on main, after each merge, so pull requests
|
||||
# never edit them (two pull requests that each regenerated one would always conflict):
|
||||
# THIRD-PARTY-LICENSES.txt (`cargo xtask licences`) and docs/features.md (`cargo xtask features`).
|
||||
# Pending files (assets/locales/pending, attribution/pending) are folded into the catalogs and
|
||||
# ATTRIBUTION.md by `cargo xtask fold-pending`.
|
||||
name: Generated files
|
||||
|
||||
on:
|
||||
@@ -17,6 +19,8 @@ on:
|
||||
- "crates/engine/**"
|
||||
- "apps/solidcraft-cli/**"
|
||||
- "xtask/**"
|
||||
- "assets/locales/pending/*.json"
|
||||
- "attribution/pending/*.md"
|
||||
- ".github/workflows/generated.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -48,13 +52,15 @@ jobs:
|
||||
run: cargo xtask licences
|
||||
- name: docs/features.md
|
||||
run: cargo xtask features
|
||||
- name: Pending locale strings and attribution rows
|
||||
run: cargo xtask fold-pending
|
||||
- name: Commit if changed
|
||||
run: |
|
||||
if git diff --quiet -- THIRD-PARTY-LICENSES.txt docs/features.md; then echo "unchanged"; exit 0; fi
|
||||
if [ -z "$(git status --porcelain)" ]; then echo "unchanged"; exit 0; fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add THIRD-PARTY-LICENSES.txt docs/features.md
|
||||
git commit -m "Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]"
|
||||
git add -A THIRD-PARTY-LICENSES.txt docs/features.md assets/locales ATTRIBUTION.md attribution
|
||||
git commit -m "Regenerate generated files and fold pending files [skip ci]"
|
||||
for _ in 1 2 3; do
|
||||
git pull --rebase origin main && git push origin HEAD:main && exit 0
|
||||
sleep 10
|
||||
|
||||
@@ -39,6 +39,8 @@ jobs:
|
||||
run: python3 packaging/tests/nightly_test.py
|
||||
- name: Windows executable icon validation regression
|
||||
run: python3 packaging/tests/windows_icon_test.py
|
||||
- name: AppImage CLI wrapper regression
|
||||
run: python3 packaging/tests/apprun_test.py
|
||||
- name: Flatpak desktop smoke validation regression
|
||||
run: python3 packaging/tests/flatpak_test.py
|
||||
- name: PowerShell
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Two quick checks on every pull request (CONTRIBUTING.md, "Sending a change"): no second open PR
|
||||
# for an issue a lower-numbered one already closes, and the "Changes a default:" line is filled in.
|
||||
# Neither is a required status check. The logic is in scripts/pr-checks/pr-checks.js (tested in ci.yml).
|
||||
name: PR checks
|
||||
|
||||
on:
|
||||
# pull_request, never pull_request_target: nothing here checks out or runs the PR's code, and the
|
||||
# token is read-only for fork PRs. Only scripts/pr-checks from the PR's own merge commit is run.
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened, ready_for_review, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
duplicate:
|
||||
name: Duplicate PR check
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/pr-checks
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const checks = require('./scripts/pr-checks/pr-checks.js');
|
||||
const r = await checks.checkDuplicate({ github, context, core });
|
||||
core.info(JSON.stringify(r));
|
||||
|
||||
defaults:
|
||||
name: Defaults line check
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/pr-checks
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const checks = require('./scripts/pr-checks/pr-checks.js');
|
||||
const r = await checks.checkDefaults({ github, context, core });
|
||||
core.info(JSON.stringify(r));
|
||||
|
||||
# Labelling needs write access, which a fork PR's token never has, so it is its own job and best
|
||||
# effort: a fork PR just doesn't get the `changes-default` label from here.
|
||||
label:
|
||||
name: Label changes-default
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/pr-checks
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const checks = require('./scripts/pr-checks/pr-checks.js');
|
||||
await checks.syncDefaultLabel({ github, context, core });
|
||||
@@ -4,11 +4,12 @@
|
||||
# comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes
|
||||
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
|
||||
# At midnight Eastern, changed main commits build and publish a nightly pre-release.
|
||||
# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
|
||||
# A version with a suffix (0.2.0-rc.1) is a pre-release.
|
||||
# The macOS app is signed and notarized when the APPLE_* secrets exist, nothing else is signed yet.
|
||||
#
|
||||
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
|
||||
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
|
||||
# macOS universal .dmg, and the CLI as a .zip packaging/macos
|
||||
# macOS universal .dmg, and the CLI as a .zip (both archs built in parallel) packaging/macos
|
||||
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
|
||||
# Web a .zip of the static site packaging/web
|
||||
#
|
||||
@@ -295,11 +296,19 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
# The two halves of the universal binary build in parallel on their own runners (each alone
|
||||
# took about half of the old single job's 45 minutes); `macos` below joins and packages them.
|
||||
macos-build:
|
||||
name: macOS build ${{ matrix.arch }}
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-14
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: aarch64, target: aarch64-apple-darwin }
|
||||
- { arch: x86_64, target: x86_64-apple-darwin }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -307,12 +316,58 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Build and package (dmg, CLI zip; the dmg is mounted and checked)
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
key: release-macos-${{ matrix.arch }}
|
||||
- name: Build
|
||||
run: packaging/macos/package.sh --build-only ${{ matrix.target }}
|
||||
# Not named release-*: the release job collects every release-* artifact as an asset.
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: macos-bin-${{ matrix.arch }}
|
||||
path: |
|
||||
target/${{ matrix.target }}/release/solidcraft
|
||||
target/${{ matrix.target }}/release/solidcraft-cli
|
||||
target/${{ matrix.target }}/release/solvecraft-cli
|
||||
if-no-files-found: error
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: [version, macos-build]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-14
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: macos-bin-aarch64
|
||||
path: target/aarch64-apple-darwin/release
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: macos-bin-x86_64
|
||||
path: target/x86_64-apple-darwin/release
|
||||
- name: Set up Developer ID signing and notarization
|
||||
# Optional until the APPLE_* secrets exist (docs/releasing.md, "macOS signing"). Without
|
||||
# them (forks, dry runs) the app keeps its ad-hoc signature and is not notarized.
|
||||
env:
|
||||
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
run: packaging/macos/signing.sh --setup
|
||||
- name: Join, sign, package and check (dmg, CLI zip; the dmg is mounted and checked)
|
||||
run: |
|
||||
# Artifacts don't keep the executable bit.
|
||||
chmod +x target/*-apple-darwin/release/solidcraft target/*-apple-darwin/release/solidcraft-cli target/*-apple-darwin/release/solvecraft-cli
|
||||
packaging/macos/package.sh --arch universal --skip-build ${MACOS_SIGN_IDENTITY:+--sign}
|
||||
- name: Remove the signing keychain and API key
|
||||
if: always()
|
||||
run: packaging/macos/signing.sh --cleanup
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
|
||||
@@ -35,7 +35,8 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers), 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) and the Windows-only
|
||||
test of the window without OS decorations, and gives its class the icon the taskbar falls back
|
||||
on) and the Windows-only
|
||||
`apps/solidcraft/src/space_mouse/windows_hid.rs` (asks Plug and Play which HID interfaces are 3D
|
||||
mice, so no other device is sent anything).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
|
||||
@@ -155,6 +156,9 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- If someone already has it, comment on their PR or issue instead of opening a second one. Two
|
||||
PRs for one change can't both land, and one of them is wasted work and CI.
|
||||
|
||||
- Keep the `Changes a default:` line in the PR description filled in, and don't open a second PR for
|
||||
an issue a lower-numbered open PR already closes; `pr-checks.yml` fails both (CONTRIBUTING.md, "Sending a change").
|
||||
|
||||
## Claim = a draft PR
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
@@ -214,3 +218,5 @@ workflow; never regenerate them in a PR.
|
||||
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
|
||||
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
|
||||
then by id, generated files left to `main`, and a design-format change as one new file.
|
||||
New UI strings go in a new `assets/locales/pending/<slug>.json` and new ATTRIBUTION.md rows in a new
|
||||
`attribution/pending/<slug>.md`, never into the catalogs or the table (see the README in each folder).
|
||||
|
||||
@@ -101,6 +101,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| tests/step-conformance/tiny_pin.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/torus_ring_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/washer.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-regressions/rod_doubled_edge.step | SolidCraft contributors | written by SolidCraft from the commands of #4598 (a bored rod moved 100 mm and exported); import regression fixture | MIT OR Apache-2.0 |
|
||||
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
@@ -512,6 +513,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
|
||||
| docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/sponsors/blacksmith-ci-powered.png | Blacksmith (blacksmith.sh) | "CI powered by Blacksmith" sponsor banner supplied by Blacksmith for the sponsorship credit; used only to credit the sponsor, not part of the application | © Blacksmith, used with permission |
|
||||
| assets/icons/ui/design/svg/model/path_flange.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
@@ -280,6 +280,9 @@ and a fresh CI run:
|
||||
after each merge by the Generated files workflow (`cargo xtask licences`, `cargo xtask
|
||||
features`). Don't regenerate or edit them in a pull request.
|
||||
- **Design format**: one file per change, never an edit to a shared number (above).
|
||||
- **New UI strings and attribution rows**: one new file per change, `assets/locales/pending/<slug>.json`
|
||||
and `attribution/pending/<slug>.md`, instead of editing the catalogs or the ATTRIBUTION.md table;
|
||||
`cargo xtask fold-pending` folds them on `main` (see the README in each folder).
|
||||
|
||||
## Sending a change
|
||||
|
||||
@@ -346,6 +349,10 @@ owning one area, with a human setting direction and reporting bugs. Their workin
|
||||
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
|
||||
long as the change meets the rules above.
|
||||
|
||||
Two quick checks run on every pull request (`.github/workflows/pr-checks.yml`, not required): it
|
||||
fails when a lower-numbered open PR by someone else already closes the same issue (the label
|
||||
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt).
|
||||
|
||||
## Labels and triage
|
||||
|
||||
The triage bot labels every new issue and PR (`.github/workflows/triage.yml`). It only adds labels;
|
||||
|
||||
Generated
+20
-17
@@ -4208,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4240,11 +4240,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4256,7 +4256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4265,7 +4265,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4282,7 +4282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"schemars",
|
||||
@@ -4296,12 +4296,13 @@ dependencies = [
|
||||
"solidcraft-render",
|
||||
"solidcraft-sketch",
|
||||
"thiserror 2.0.21",
|
||||
"toml_edit",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4310,7 +4311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4330,13 +4331,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
"cadmpeg-registry",
|
||||
"chardetng",
|
||||
"encoding_rs",
|
||||
"gethostname",
|
||||
"gltf",
|
||||
"log",
|
||||
"quick-xml",
|
||||
@@ -4353,7 +4355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4373,7 +4375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4382,7 +4384,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4396,7 +4398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4407,14 +4409,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4443,7 +4445,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4698,6 +4700,7 @@ dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
@@ -5979,7 +5982,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"serde_json",
|
||||
|
||||
+4
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.22.0"
|
||||
version = "0.22.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -67,6 +67,9 @@ encoding_rs = "0.8"
|
||||
# glTF 2.0 import (MIT OR Apache-2.0): the document model and GLB container only; buffers are
|
||||
# read by crates/io itself, so no image decoding or file access comes with it.
|
||||
gltf = { version = "1.4", default-features = false, features = ["names"] }
|
||||
# The computer's name in a design's "open elsewhere" lock file (MIT OR Apache-2.0; already in the
|
||||
# tree through x11rb).
|
||||
gethostname = "1"
|
||||
|
||||
# FreeCAD and Rhino import (Apache-2.0, pure Rust; native and browser builds).
|
||||
# Pinned: its IR and codec APIs change between minor versions. Open formats only: no default
|
||||
|
||||
+11
-1
@@ -52,6 +52,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| M0 | Vertical slice | done | — |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces, at distances from reference edges, and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), drawing snaps to where two lines, circles or arcs cross (#4967), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks; tiny rim edges remain selected instead of nearby wall seams when filleting (#2498) | in progress | 30 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop, browser and CLI), 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 |
|
||||
@@ -61,7 +63,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| 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), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), 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), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements, and exported as an animation coming apart and back together); 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, Cam 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 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 |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. macOS Developer ID signing and notarization are in the release workflow (#5749; they switch on when the Apple secrets exist). Windows installer 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 |
|
||||
| M15 | Frames and weldments (#4003): structural members along 2D and 3D sketch lines, butt and mitre corners, trim to faces and bodies, end caps, gussets, cosmetic welds and a cut list. Done: the profile library (`frame.profile.list`, `frame.profile.properties`, `frame.profile.insert`): bar, round bar, rectangular and round tube, angle, channel and I-beam, each SolidCraft's own parametric definition with exact fillet arcs and exact section properties (area, centroid, second moments), tested against hand-computed values; members (`frame.member`): one body per straight line of a 2D or 3D frame sketch, following parameter edits, with a built-in profile (its dimensions parameter expressions) or a profile sketch (its drawn material by alternating nested loops), anchor, angle and mirror; each member's name kept with its line. Left: corners, trims, end caps, gussets, welds, cut list, FRAME tab and dialogs | in progress | 60 |
|
||||
@@ -204,6 +206,11 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- Operation length fields share design-unit defaults and input evaluation, including secondary
|
||||
distances, thin walls, blend controls, sheet/plastic rules, joints and motion keys. Canvas
|
||||
handles and sketch previews use the same lengths as committed geometry. Existing stored
|
||||
millimetre values retain their size when edited in a design using another unit (#3271).
|
||||
|
||||
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
|
||||
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
|
||||
Both tools retain full replies by default.
|
||||
@@ -247,6 +254,9 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
- Sketch Dimension takes a line and a circle or arc (#1803): it measures from the line to the
|
||||
circle's edge (the gap, 0 when tangent, or how far the edge reaches past a line that cuts
|
||||
it), drives it like any other dimension and keeps the circle on its side of the line.
|
||||
- Sketch Dimension takes a point and a circle or arc, or two circles or arcs (#4064): it
|
||||
measures to the circle's edge (from the point, or between the two edges: the gap, 0 when
|
||||
they touch, how far they overlap, or the narrowest gap between nested circles).
|
||||
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
|
||||
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
|
||||
the current design unit; changing units leaves existing geometry and expressions intact.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
//! solidcraft-cli mcp [--headless] [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
|
||||
//! [--param-check off|warn|strict]
|
||||
//! MCP server on stdio (docs/mcp.md)
|
||||
//! solidcraft-cli mcp setup [--dry-run] [--client NAME] [--live] (alias: mcp install) add it to OpenCode, Claude Desktop, Claude Code, Cursor
|
||||
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
|
||||
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
||||
//!
|
||||
@@ -82,7 +83,7 @@ const USAGE_ORACLE: &str = " solidcraft-cli oracle <case-dir>... [--json]\n";
|
||||
const USAGE_FUZZ: &str =
|
||||
" solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]\n";
|
||||
/// `mcp`'s lines in the usage.
|
||||
const USAGE_MCP: &str = " solidcraft-cli mcp [--headless] [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]\n [--param-check off|warn|strict] [--render-gpu]\n";
|
||||
const USAGE_MCP: &str = " solidcraft-cli mcp [--headless] [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]\n [--param-check off|warn|strict] [--render-gpu]\n solidcraft-cli mcp setup [--dry-run] [--client opencode|claude-desktop|claude-code|cursor] [--live]\n (adds a `solidcraft` server, with this program's path, to each installed AI client; --live adds `solidcraft-live` for the running app; alias: mcp install)\n";
|
||||
/// `crashes`'s lines in the usage.
|
||||
const USAGE_CRASHES: &str = " solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]\n";
|
||||
/// `licences`'s lines in the usage.
|
||||
@@ -625,10 +626,23 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `mcp setup`: add the server to each installed AI client, with this program's own path.
|
||||
fn cmd_mcp_setup(args: &[String]) -> Result<(), String> {
|
||||
use solidcraft_engine::agent_setup as setup;
|
||||
let (lines, ok) = setup::setup(args, &setup::Env::from_process(), &setup::current_exe()?)?;
|
||||
for l in lines {
|
||||
println!("{l}");
|
||||
}
|
||||
if ok { Ok(()) } else { Err("mcp setup: some clients could not be configured".into()) }
|
||||
}
|
||||
|
||||
/// MCP server on stdio: headless (optionally starting from a design or script) or bridged to
|
||||
/// a running app's control port. stdout carries only protocol messages. Files the agent's tool
|
||||
/// calls reach are confined to `solidcraft_mcp::agent_scope` unless `--allow-any-path` is given.
|
||||
fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
if matches!(args.first().map(String::as_str), Some("setup" | "install")) {
|
||||
return cmd_mcp_setup(&args[1..]);
|
||||
}
|
||||
let render_gpu = args.iter().any(|arg| arg == "--render-gpu");
|
||||
if render_gpu && flag(args, "--connect").is_some() {
|
||||
return Err("mcp: --render-gpu installs a headless image host; a connected application owns its own device".into());
|
||||
@@ -684,9 +698,8 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
Box::new(headless)
|
||||
}
|
||||
(None, None) => {
|
||||
if !args.iter().any(|a| a == "--headless")
|
||||
&& let Some(port) = discover_app()
|
||||
{
|
||||
let auto = !args.iter().any(|a| a == "--headless");
|
||||
if auto && let Some(port) = discover_app() {
|
||||
eprintln!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
|
||||
return serve_mcp(Box::new(solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token"))), scope);
|
||||
}
|
||||
@@ -695,7 +708,12 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
if render_gpu {
|
||||
headless.session.register_render_job_host(solidcraft_gpu::path::host().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Box::new(headless)
|
||||
if auto {
|
||||
// The app may start after the agent: look for it again until there is work here.
|
||||
Box::new(solidcraft_mcp::Auto::new(headless, Box::new(discover_app), flag(args, "--token")))
|
||||
} else {
|
||||
Box::new(headless)
|
||||
}
|
||||
}
|
||||
};
|
||||
serve_mcp(backend, scope)
|
||||
|
||||
@@ -151,3 +151,115 @@ fn mcp_without_arguments_finds_the_running_app() {
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("bridged to the running app"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
std::fs::remove_dir_all(&control_dir).ok();
|
||||
}
|
||||
|
||||
/// `mcp setup` against a temporary home: the client folders it finds are the only ones it writes.
|
||||
mod setup {
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn home(name: &str) -> PathBuf {
|
||||
let h = std::env::temp_dir().join(format!("solidcraft-mcp-setup-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&h);
|
||||
std::fs::create_dir_all(&h).unwrap();
|
||||
h
|
||||
}
|
||||
|
||||
fn run(home: &Path, args: &[&str]) -> std::process::Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "setup"])
|
||||
.args(args)
|
||||
.env_clear()
|
||||
.env("HOME", home)
|
||||
.env("USERPROFILE", home)
|
||||
.env("APPDATA", home.join("AppData/Roaming"))
|
||||
.env("LOCALAPPDATA", home.join("AppData/Local"))
|
||||
.env("XDG_CONFIG_HOME", home.join(".config"))
|
||||
.env("PATH", "")
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn json_of(p: &Path) -> Value {
|
||||
serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn exe() -> String {
|
||||
let p = Path::new(env!("CARGO_BIN_EXE_solidcraft-cli")).canonicalize().unwrap();
|
||||
p.to_string_lossy().trim_start_matches(r"\\?\").to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_into_opencode_and_cursor_keeping_other_servers() {
|
||||
let h = home("merge");
|
||||
let oc = h.join(".config/opencode");
|
||||
std::fs::create_dir_all(&oc).unwrap();
|
||||
let old = json!({"$schema": "https://opencode.ai/config.json", "theme": "x", "mcp": {"other": {"type": "local", "command": ["o"]}}});
|
||||
std::fs::write(oc.join("opencode.json"), old.to_string()).unwrap();
|
||||
std::fs::create_dir_all(h.join(".cursor")).unwrap();
|
||||
std::fs::write(h.join(".cursor/mcp.json"), r#"{"mcpServers": {"other": {"command": "o"}}}"#).unwrap();
|
||||
|
||||
let out = run(&h, &["--live"]);
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let v = json_of(&oc.join("opencode.json"));
|
||||
assert_eq!(v["theme"], "x");
|
||||
assert_eq!(v["mcp"]["other"], old["mcp"]["other"]);
|
||||
assert_eq!(v["mcp"]["solidcraft"], json!({"type": "local", "command": [exe(), "mcp"], "enabled": true}));
|
||||
assert_eq!(v["mcp"]["solidcraft-live"]["command"], json!([exe(), "mcp", "--connect", "127.0.0.1:7878"]));
|
||||
assert_eq!(json_of(&oc.join("opencode.json.bak")), old);
|
||||
let c = json_of(&h.join(".cursor/mcp.json"));
|
||||
assert_eq!(c["mcpServers"]["other"], json!({"command": "o"}));
|
||||
assert_eq!(c["mcpServers"]["solidcraft"], json!({"command": exe(), "args": ["mcp"]}));
|
||||
|
||||
// Codex (TOML) and Claude Code (a big file the CLI owns) are edited in place too.
|
||||
std::fs::create_dir_all(h.join(".codex")).unwrap();
|
||||
std::fs::write(h.join(".codex/config.toml"), "# mine\nmodel = \"x\"\n").unwrap();
|
||||
let cc = "{\n \"numStartups\": 3,\n \"projects\": {}\n}\n";
|
||||
std::fs::write(h.join(".claude.json"), cc).unwrap();
|
||||
assert!(run(&h, &[]).status.success());
|
||||
let toml = std::fs::read_to_string(h.join(".codex/config.toml")).unwrap();
|
||||
assert!(toml.starts_with("# mine\nmodel = \"x\"\n") && toml.contains("[mcp_servers.solidcraft]"), "{toml}");
|
||||
let c = json_of(&h.join(".claude.json"));
|
||||
assert_eq!(c["numStartups"], 3);
|
||||
assert_eq!(c["mcpServers"]["solidcraft"], json!({"type": "stdio", "command": exe(), "args": ["mcp"]}));
|
||||
assert_eq!(std::fs::read_to_string(h.join(".claude.json.bak")).unwrap(), cc);
|
||||
|
||||
// Running again updates the same entries and leaves one of each.
|
||||
assert!(run(&h, &[]).status.success());
|
||||
assert_eq!(json_of(&h.join(".cursor/mcp.json"))["mcpServers"].as_object().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_writes_nothing_and_missing_clients_are_skipped() {
|
||||
let h = home("dry");
|
||||
std::fs::create_dir_all(h.join(".cursor")).unwrap();
|
||||
let out = run(&h, &["--dry-run"]);
|
||||
assert!(out.status.success());
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains("would write") && text.contains("solidcraft"), "{text}");
|
||||
assert!(!h.join(".cursor/mcp.json").exists());
|
||||
assert!(!h.join(".config/opencode").exists(), "OpenCode was not installed, so nothing is created for it");
|
||||
let out = run(&h, &["--client", "claude-desktop"]);
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_json_is_left_alone() {
|
||||
let h = home("bad");
|
||||
std::fs::create_dir_all(h.join(".cursor")).unwrap();
|
||||
std::fs::write(h.join(".cursor/mcp.json"), "{ // comment").unwrap();
|
||||
let out = run(&h, &[]);
|
||||
assert!(!out.status.success());
|
||||
assert_eq!(std::fs::read_to_string(h.join(".cursor/mcp.json")).unwrap(), "{ // comment");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_msix_folder_is_found() {
|
||||
let h = home("msix");
|
||||
let pkg = h.join("AppData/Local/Packages/Claude_abc/LocalCache/Roaming/Claude");
|
||||
std::fs::create_dir_all(&pkg).unwrap();
|
||||
// The MSIX folder is only read through LOCALAPPDATA, set for every platform here.
|
||||
assert!(run(&h, &["--client", "claude-desktop"]).status.success());
|
||||
assert!(json_of(&pkg.join("claude_desktop_config.json"))["mcpServers"]["solidcraft"].is_object());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,13 +230,15 @@ mod web {
|
||||
fn sync_recents(&mut self) {
|
||||
let mut entries = self.files.entries.borrow().clone();
|
||||
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
|
||||
let forgotten = self.app.home.forgotten.clone();
|
||||
let recent = &mut self.app.home.recent;
|
||||
let known = self.files.disk_known.borrow().clone();
|
||||
// A design on the computer's disk stays listed while its file is remembered.
|
||||
recent.retain(|p| vfs::exists(p) || disk::parse(p).is_some_and(|(id, _)| known.contains(id)));
|
||||
for e in entries.iter().filter(|e| solidcraft_engine::io::is_design_path(&e.name)) {
|
||||
let p = format!("{}{}", files::OPFS, e.name);
|
||||
if !recent.contains(&p) {
|
||||
// A design the user removed from the list is not added back while it is stored.
|
||||
if !recent.contains(&p) && !forgotten.contains(&p) {
|
||||
recent.push(p);
|
||||
}
|
||||
}
|
||||
@@ -245,6 +247,10 @@ mod web {
|
||||
let recovered = self.files.recovery.borrow().as_ref().and_then(|(_, p)| p.clone());
|
||||
let keep = recent.iter().chain(recovered.as_ref()).filter_map(|p| disk::parse(p)).map(|(id, _)| id.to_string()).collect();
|
||||
disk::prune(keep);
|
||||
// Drop forgotten entries whose stored design is gone, so the set does not grow without bound.
|
||||
let stored: std::collections::BTreeSet<String> =
|
||||
entries.iter().filter(|e| solidcraft_engine::io::is_design_path(&e.name)).map(|e| format!("{}{}", files::OPFS, e.name)).collect();
|
||||
self.app.home.forgotten.retain(|p| stored.contains(p));
|
||||
}
|
||||
|
||||
/// The stored designs: open, insert, rename, delete, download; open from the computer.
|
||||
@@ -383,6 +389,8 @@ mod web {
|
||||
"delete" => {
|
||||
files::delete(&self.files, &name);
|
||||
self.app.home.recent.retain(|r| *r != path);
|
||||
// The file is gone, so there is nothing left to keep forgotten.
|
||||
self.app.home.forgotten.remove(&path);
|
||||
}
|
||||
"rename_cancel" => self.renaming = None,
|
||||
"rename" => {
|
||||
@@ -396,6 +404,10 @@ mod web {
|
||||
*r = new.clone();
|
||||
}
|
||||
}
|
||||
// A forgotten design follows its new name.
|
||||
if self.app.home.forgotten.remove(&old) {
|
||||
self.app.home.forgotten.insert(new);
|
||||
}
|
||||
}
|
||||
Err(e) => self.app.set_status(e, true),
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ raytrace-cpu = ["solidcraft-engine/raytrace-cpu"]
|
||||
|
||||
# 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].
|
||||
# (Windows resize border, taskbar icon) can allow it: none has a safe API. Keep the rest in sync with [workspace.lints].
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
@@ -54,7 +54,8 @@ hidapi = { version = "2.6.7", default-features = false, features = ["windows-nat
|
||||
# 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.
|
||||
# The window's resize border, DPI size and class icon (src/native_frame.rs): the window procedure, hit
|
||||
# test, DPI, the exe's icon.
|
||||
# Finding 3D mice through Plug and Play without asking other devices anything (src/space_mouse/windows_hid.rs).
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Devices_DeviceAndDriverInstallation",
|
||||
@@ -81,9 +82,10 @@ solidcraft-3dx-client = { workspace = true }
|
||||
# The 3D mouse (src/space_mouse.rs), in the shared-device open mode so that a running 3DxWare
|
||||
# driver does not shut the app out.
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["macos-shared-device"] }
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs), and the fullscreen title-bar
|
||||
# inset (src/main.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-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
|
||||
|
||||
+154
-13
@@ -111,6 +111,13 @@ impl eframe::App for App {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
}
|
||||
solidcraft_engine::crash::phase(solidcraft_engine::crash::RUNNING);
|
||||
// A DMG install has no installer step: put solidcraft-cli on PATH, quietly.
|
||||
#[cfg(target_os = "macos")]
|
||||
std::thread::spawn(solidcraft_ui_egui::cli_link::install_on_launch);
|
||||
// Automation runs (a control port was asked for) leave the user's AI apps alone.
|
||||
if !self.take_focus_unwanted {
|
||||
solidcraft_ui_egui::ai_connect::begin(&mut self.solve);
|
||||
}
|
||||
if let Some(dir) = self.starting.take() {
|
||||
startup::started(&dir);
|
||||
}
|
||||
@@ -147,6 +154,8 @@ impl eframe::App for App {
|
||||
if let Some(menu) = &mut self.native_menu {
|
||||
menu.raw_input_hook(&mut self.solve, ctx, raw);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_fullscreen_content(ctx, raw);
|
||||
// Before the app's own hook, which adds the control channel's events.
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw);
|
||||
@@ -156,7 +165,7 @@ impl eframe::App for App {
|
||||
self.solve.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.solve.ui(ui);
|
||||
content_ui(&mut self.solve, ui);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs());
|
||||
@@ -166,7 +175,13 @@ impl eframe::App for App {
|
||||
solidcraft_ui_egui::agent_chat::shutdown(&mut self.solve);
|
||||
// A save still being written ends before the app does.
|
||||
solidcraft_ui_egui::save_job::tick(&mut self.solve, true);
|
||||
// Pictures still being drawn off the window thread (Home cards, file previews) end
|
||||
// before the GPU is torn down: one drawn while the process exits can crash the driver.
|
||||
if !solidcraft_engine::capture::close(std::time::Duration::from_secs(5)) {
|
||||
log::warn!("a picture was still being drawn as the app closed");
|
||||
}
|
||||
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
solidcraft_ui_egui::open_elsewhere::close(&mut self.solve);
|
||||
solidcraft_engine::crash::end_session();
|
||||
if let Some(lost_at) = self.gpu.as_ref().and_then(|g| g.lost_at) {
|
||||
device_lost::restart(self.gpu.as_ref().is_some_and(|g| g.restart_on_software), lost_at);
|
||||
@@ -232,6 +247,37 @@ fn case_variants(exts: &[&str]) -> Vec<String> {
|
||||
all
|
||||
}
|
||||
|
||||
/// egui's root Ui spans the whole viewport, including native safe areas. Lay out the app
|
||||
/// inside the unobscured rectangle so its application bar can receive native pointer input.
|
||||
fn content_ui(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let content = ui.ctx().content_rect();
|
||||
let above = egui::Rect::from_min_max(ui.max_rect().min, egui::pos2(ui.max_rect().right(), content.top()));
|
||||
ui.painter().rect_filled(above, 0.0, solidcraft_ui_egui::theme::Tokens::get().app_bar);
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(content), |ui| app.ui(ui));
|
||||
}
|
||||
|
||||
/// A transparent macOS title bar still intercepts clicks at the top in fullscreen (#3643).
|
||||
/// `contentLayoutRect` reports the whole content view once that bar auto-hides, so reserve
|
||||
/// the native titled-window height even then. AppKit supplies the height in logical points;
|
||||
/// egui's UI zoom is separate from the display's Retina scale.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_fullscreen_content(ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
let Some(mtm) = objc2::MainThreadMarker::new() else { return };
|
||||
let frame = objc2_app_kit::NSWindow::frameRectForContentRect_styleMask(Default::default(), objc2_app_kit::NSWindowStyleMask::Titled, mtm);
|
||||
fullscreen_content_insets(raw, frame.size.height as f32, ctx.zoom_factor());
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn fullscreen_content_insets(raw: &mut egui::RawInput, title_height: f32, zoom: f32) {
|
||||
let fullscreen = raw.viewports.get(&raw.viewport_id).and_then(|v| v.fullscreen).unwrap_or(false);
|
||||
let mut insets = raw.safe_area_insets.unwrap_or_default();
|
||||
if fullscreen && title_height.is_finite() && title_height > 0.0 && zoom.is_finite() && zoom > 0.0 {
|
||||
insets.0.top = insets.0.top.max(title_height / zoom);
|
||||
}
|
||||
// Explicitly reset on leaving fullscreen: None would retain the preceding frame's inset.
|
||||
raw.safe_area_insets = Some(insets);
|
||||
}
|
||||
|
||||
fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
|
||||
let open_parent = parent.clone();
|
||||
@@ -369,7 +415,7 @@ fn agent_http(r: &solidcraft_ui_egui::agent_chat::backend::HttpRequest) -> Resul
|
||||
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_global(Some(Duration::from_secs(r.timeout_s.clamp(1, 3600))))
|
||||
.timeout_connect(Some(Duration::from_secs(10)))
|
||||
.user_agent("SolidCraft")
|
||||
.max_redirects(0)
|
||||
@@ -551,7 +597,8 @@ const FLOAT_DEPTH_BITS: u8 = 32;
|
||||
const STENCIL_BITS: u8 = 8;
|
||||
const MSAA: u16 = 4;
|
||||
|
||||
fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
/// `custom_titlebar`: no OS decorations, the application bar is the title bar.
|
||||
fn app_viewport(custom_titlebar: bool) -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
// Let macOS use the bundle's layered icon; eframe's default bitmap would replace it.
|
||||
#[cfg(target_os = "macos")]
|
||||
let icon = egui::IconData::default();
|
||||
@@ -564,7 +611,7 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_decorations(!custom_titlebar)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
@@ -656,10 +703,16 @@ fn main() -> eframe::Result {
|
||||
// module on Windows, and on every system the startup step the app was in.
|
||||
#[cfg(target_os = "windows")]
|
||||
native_crash::install();
|
||||
// Windows with only OpenGL to start: the system title bar instead of ours (#2063).
|
||||
let started = match &wgpu_options.wgpu_setup {
|
||||
eframe::egui_wgpu::WgpuSetup::CreateNew(setup) => setup.instance_descriptor.backends,
|
||||
_ => eframe::wgpu::Backends::all(),
|
||||
};
|
||||
let custom_titlebar = CUSTOM_TITLEBAR && !startup::system_title_bar(cfg!(target_os = "windows"), started);
|
||||
eframe::egui_wgpu::set_startup_hook(startup_step);
|
||||
solidcraft_engine::crash::phase("window");
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: app_viewport()?,
|
||||
viewport: app_viewport(custom_titlebar)?,
|
||||
// The window's pass (egui's) has no depth and one sample: the viewport draws its scene
|
||||
// offscreen with its own depth and MSAA, then blits it, so the UI pass pays for neither.
|
||||
depth_buffer: 0,
|
||||
@@ -723,13 +776,18 @@ fn main() -> eframe::Result {
|
||||
mark("fonts");
|
||||
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).
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
app.native_resize_border = CUSTOM_TITLEBAR && native_frame::install(cc);
|
||||
if CUSTOM_TITLEBAR && !app.native_resize_border {
|
||||
// Windows: the taskbar button shows the mark even when slow first frames keep the
|
||||
// taskbar waiting for the window's icon.
|
||||
if !native_frame::set_class_icon(cc) {
|
||||
eprintln!("SolidCraft window: no class icon; the taskbar may show a generic one");
|
||||
}
|
||||
// The OS hit test offers the resize border, at the window's DPI, from the first
|
||||
// message (#1381).
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -781,6 +839,9 @@ fn main() -> eframe::Result {
|
||||
app.session.mark_saved();
|
||||
app.session.undo.clear();
|
||||
}
|
||||
// Lock files next to open designs: a design open on another computer says so (#5467),
|
||||
// the files given at launch included.
|
||||
solidcraft_ui_egui::open_elsewhere::start(&mut app);
|
||||
// A launch from Finder: its files arrived before the window.
|
||||
#[cfg(target_os = "macos")]
|
||||
let files = {
|
||||
@@ -871,6 +932,9 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
|
||||
eprintln!("SolidCraft graphics: starting {backends:?}");
|
||||
setup.instance_descriptor.backends = backends;
|
||||
}
|
||||
let intel_mac = cfg!(all(target_os = "macos", target_arch = "x86_64"));
|
||||
let indirect = std::env::var(startup::INDIRECT_VALIDATION_ENV).ok();
|
||||
setup.instance_descriptor.flags = startup::window_instance_flags(setup.instance_descriptor.flags, intel_mac, indirect.as_deref());
|
||||
if cpu {
|
||||
setup.native_adapter_selector = Some(std::sync::Arc::new(startup::pick_cpu_adapter));
|
||||
}
|
||||
@@ -991,11 +1055,11 @@ mod backend_tests {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod icon_tests {
|
||||
mod viewport_tests {
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_preserves_the_bundle_icon_instead_of_setting_a_runtime_bitmap() {
|
||||
let viewport = super::app_viewport().expect("viewport");
|
||||
let viewport = super::app_viewport(super::CUSTOM_TITLEBAR).expect("viewport");
|
||||
assert_eq!(viewport.icon.as_deref(), Some(&eframe::egui::IconData::default()));
|
||||
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
|
||||
}
|
||||
@@ -1003,12 +1067,89 @@ mod icon_tests {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn desktop_viewport_supplies_the_original_mark_to_the_window() {
|
||||
let viewport = super::app_viewport().expect("viewport");
|
||||
let viewport = super::app_viewport(super::CUSTOM_TITLEBAR).expect("viewport");
|
||||
let icon = viewport.icon.expect("running window must have an icon");
|
||||
assert_eq!((icon.width, icon.height), (64, 64));
|
||||
assert_eq!(icon.rgba, super::app_icon::rgba(64).expect("original mark"));
|
||||
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullscreen_bar_clears_native_chrome_and_undo_redo_keep_working() {
|
||||
use egui::{Event, PointerButton, Pos2, RawInput, Rect, ViewportId, vec2};
|
||||
use serde_json::json;
|
||||
use solidcraft_ui_egui::scenario::handle_at;
|
||||
|
||||
// AppKit points are independent of Retina scale and of the user's UI zoom.
|
||||
for (zoom, retina) in [(1.0, 1.0), (1.0, 2.0), (1.5, 2.0), (2.0, 2.0)] {
|
||||
let ctx = egui::Context::default();
|
||||
solidcraft_ui_egui::fonts::setup_bundled_fonts(&ctx);
|
||||
ctx.set_zoom_factor(zoom);
|
||||
let mut app = super::SolveApp::new(super::Session::default(), Default::default());
|
||||
app.integrated_titlebar = true;
|
||||
for (command, params) in [
|
||||
("sketch.create", json!({"plane":"XY"})),
|
||||
("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"distance":15})),
|
||||
] {
|
||||
app.run(command, params).unwrap();
|
||||
}
|
||||
let mut time = 0.0;
|
||||
let mut frame = |app: &mut super::SolveApp, fullscreen, events| {
|
||||
time += 0.05;
|
||||
let mut raw = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))),
|
||||
time: Some(time),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let viewport = raw.viewports.get_mut(&ViewportId::ROOT).unwrap();
|
||||
viewport.fullscreen = Some(fullscreen);
|
||||
viewport.native_pixels_per_point = Some(retina);
|
||||
super::fullscreen_content_insets(&mut raw, 32.0, zoom);
|
||||
let _ = ctx.run_ui(raw, |ui| super::content_ui(app, ui));
|
||||
};
|
||||
// Enter, leave, and enter again: no stale inset when returning to a normal window.
|
||||
for fullscreen in [false, true, false, true] {
|
||||
frame(&mut app, fullscreen, vec![]);
|
||||
frame(&mut app, fullscreen, vec![]);
|
||||
let top = if fullscreen { 32.0 / zoom } else { 0.0 };
|
||||
for name in ["appbar:file", "appbar:undo", "appbar:redo", "appbar:help"] {
|
||||
let at = handle_at(name).unwrap();
|
||||
assert!((at.y - top - 17.0).abs() < 0.1, "{name}: {at:?}, fullscreen={fullscreen}, zoom={zoom}");
|
||||
}
|
||||
for (handle, bodies) in [("appbar:undo", 0), ("appbar:redo", 1)] {
|
||||
let at = handle_at(handle).unwrap();
|
||||
frame(&mut app, fullscreen, vec![Event::PointerMoved(at)]);
|
||||
for pressed in [true, false] {
|
||||
frame(
|
||||
&mut app,
|
||||
fullscreen,
|
||||
vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }],
|
||||
);
|
||||
}
|
||||
assert_eq!(app.session.model.state().bodies.len(), bodies, "{handle}, fullscreen={fullscreen}, zoom={zoom}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fullscreen_inset_preserves_existing_safe_areas_and_resets_on_exit() {
|
||||
use egui::epaint::MarginF32;
|
||||
use egui::{RawInput, SafeAreaInsets, ViewportId};
|
||||
let mut raw = RawInput::default();
|
||||
raw.viewports.get_mut(&ViewportId::ROOT).unwrap().fullscreen = Some(true);
|
||||
raw.safe_area_insets = Some(SafeAreaInsets(MarginF32 { top: 40.0, left: 5.0, right: 6.0, bottom: 7.0 }));
|
||||
super::fullscreen_content_insets(&mut raw, 32.0, 2.0);
|
||||
assert_eq!(raw.safe_area_insets.unwrap().0, MarginF32 { top: 40.0, left: 5.0, right: 6.0, bottom: 7.0 });
|
||||
|
||||
raw.viewports.get_mut(&ViewportId::ROOT).unwrap().fullscreen = Some(false);
|
||||
raw.safe_area_insets = None;
|
||||
super::fullscreen_content_insets(&mut raw, 32.0, 1.0);
|
||||
assert_eq!(raw.safe_area_insets, Some(SafeAreaInsets::default()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -15,10 +15,14 @@
|
||||
//! 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).
|
||||
//! Replacing a window procedure and setting a window class's icon have 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.
|
||||
//!
|
||||
//! [`set_class_icon`] gives the window's class the exe's icon, so the taskbar button shows the
|
||||
//! mark even when the window is too busy to answer the taskbar's request for its icon.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
@@ -27,12 +31,18 @@ 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::Graphics::Gdi::{GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromWindow};
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
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,
|
||||
CallWindowProcW, GCLP_HICON, GCLP_HICONSM, GWL_STYLE, GWLP_WNDPROC, GetClientRect, GetWindowLongPtrW, GetWindowRect, HTBOTTOM, HTBOTTOMLEFT,
|
||||
HTBOTTOMRIGHT, HTLEFT, HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, IMAGE_ICON, IsIconic, IsZoomed, LR_DEFAULTCOLOR, LoadImageW, SM_CXICON,
|
||||
SM_CXPADDEDBORDER, SM_CXSIZEFRAME, SM_CXSMICON, SetWindowLongPtrW, WM_GETDPISCALEDSIZE, WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME,
|
||||
};
|
||||
// windows-sys has SetClassLongPtrW only on 64-bit targets; on 32-bit Windows it is SetClassLongW.
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::SetClassLongPtrW;
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::SetClassLongW as SetClassLongPtrW;
|
||||
|
||||
/// The work area (the monitor without the taskbar) of the monitor nearest the window, in physical
|
||||
/// pixels: left, top, width, height.
|
||||
@@ -54,6 +64,48 @@ pub fn work_area(window: &impl HasWindowHandle) -> Option<(i32, i32, i32, i32)>
|
||||
Some((rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top))
|
||||
}
|
||||
|
||||
/// The exe's icon resource (`build.rs` embeds it as `1 ICON`).
|
||||
const ICON_RESOURCE: usize = 1;
|
||||
|
||||
/// Give the main window's class the exe's icon, big and small. Call it before the window is
|
||||
/// shown (eframe's app creator).
|
||||
///
|
||||
/// The taskbar asks a window for its icon when the window appears. eframe sets the window's
|
||||
/// icon before that, while the window is still hidden, and then shows the window at the end of
|
||||
/// its first frame; if that frame is slow, or startup kept the thread busy long enough for
|
||||
/// Windows to mark the window as not responding, the request isn't answered in time. The taskbar then
|
||||
/// takes the window class's icon and doesn't ask again. winit registers its class without one,
|
||||
/// so the button showed Windows' generic icon for as long as the window was open.
|
||||
/// `false` if it isn't a Win32 window or the exe's icon doesn't load; SetClassLongPtrW's own
|
||||
/// failure can't be told from a class that had no icon (both return 0), so it isn't reported.
|
||||
pub fn set_class_icon(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;
|
||||
// SAFETY: a plain query on the live main window.
|
||||
let dpi = unsafe { GetDpiForWindow(hwnd) };
|
||||
let (Some(big), Some(small)) = (exe_icon(SM_CXICON, dpi), exe_icon(SM_CXSMICON, dpi)) else { return false };
|
||||
// SAFETY: `hwnd` is the live main window and both icons are valid handles. The class keeps
|
||||
// them for the rest of the process, so they are never destroyed. `as _`: on 32-bit Windows
|
||||
// this is SetClassLongW (see the imports), which takes an i32.
|
||||
unsafe {
|
||||
SetClassLongPtrW(hwnd, GCLP_HICON, big as _);
|
||||
SetClassLongPtrW(hwnd, GCLP_HICONSM, small as _);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The exe's icon at the size of system metric `metric` (`SM_CXICON`, `SM_CXSMICON`) at `dpi`.
|
||||
fn exe_icon(metric: i32, dpi: u32) -> Option<windows_sys::Win32::Foundation::HANDLE> {
|
||||
// SAFETY: GetModuleHandleW(null) is this exe, loaded for the life of the process; the name
|
||||
// is an integer resource id (MAKEINTRESOURCE), which LoadImageW accepts in place of a string.
|
||||
let icon = unsafe {
|
||||
let size = GetSystemMetricsForDpi(metric, dpi);
|
||||
LoadImageW(GetModuleHandleW(std::ptr::null()), std::ptr::without_provenance(ICON_RESOURCE), IMAGE_ICON, size, size, LR_DEFAULTCOLOR)
|
||||
};
|
||||
(!icon.is_null()).then_some(icon)
|
||||
}
|
||||
|
||||
/// The window procedure ours replaced (winit's), as an address; 0 before [`install`].
|
||||
static PREVIOUS: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ impl NativeMenu {
|
||||
("messages", "Messages\u{2026}"),
|
||||
("report", "Report an Issue\u{2026}"),
|
||||
("crashes", "Crash Reports\u{2026}"),
|
||||
("install_cli", "Install solidcraft-cli command"),
|
||||
] {
|
||||
let item = MenuItem::new(app.tr_label(label), true, None);
|
||||
help.append(&item)?;
|
||||
|
||||
@@ -521,6 +521,119 @@ fn hid_api() -> hidapi::HidResult<HidApi> {
|
||||
HidApi::new()
|
||||
}
|
||||
|
||||
/// An open 3D-mouse interface: the device, how its reports are laid out, and its vendor and
|
||||
/// product ids.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", test))]
|
||||
struct Candidate<D> {
|
||||
device: D,
|
||||
layout: Layout,
|
||||
ids: (u16, u16),
|
||||
}
|
||||
|
||||
/// Input reports read with a timeout in milliseconds: hidapi's devices, and the tests' simulated
|
||||
/// ones. `Ok(0)` is a timeout; an error means the device is gone.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", test))]
|
||||
trait ReportSource {
|
||||
fn read_report(&self, report: &mut [u8], timeout_ms: i32) -> Result<usize, String>;
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
impl ReportSource for hidapi::HidDevice {
|
||||
fn read_report(&self, report: &mut [u8], timeout_ms: i32) -> Result<usize, String> {
|
||||
self.read_timeout(report, timeout_ms).map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// What following the candidates tells the reader.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", test))]
|
||||
enum Followed<'a> {
|
||||
/// This device is the one read now, so its calibration applies. With the buttons it last
|
||||
/// reported down, when it reported them while another was read: they are its baseline, so a
|
||||
/// press that makes it the device in use counts as a press, not as a button already held.
|
||||
Device((u16, u16), Option<u32>),
|
||||
/// An input report from it.
|
||||
Report(&'a Layout, &'a [u8]),
|
||||
/// It is gone.
|
||||
Gone,
|
||||
}
|
||||
|
||||
/// Whether a report shows its device in use: an axis off zero or a button down. A cap at rest
|
||||
/// sends zeros at most, and an idle 3Dconnexion Universal Receiver sends nothing at all.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", test))]
|
||||
fn shows_use(layout: Layout, report: &[u8]) -> bool {
|
||||
layout.decode(report).is_some_and(|decoded| decoded.axes.iter().flatten().any(|axis| *axis != 0) || decoded.buttons.is_some_and(|down| down != 0))
|
||||
}
|
||||
|
||||
/// Reads every candidate and follows the one in use (#3988). The first is followed from the start,
|
||||
/// as when it was the only one read. Another takes over once it shows use while the followed one
|
||||
/// has shown none for `STALE_AFTER`, so two devices moved together don't hand the reading back
|
||||
/// and forth. An idle Universal Receiver opens fine and never fails; read on its own it kept a
|
||||
/// SpaceMouse next to it from ever being read. A candidate that fails is dropped. If it was the
|
||||
/// followed one, the reading is reset and the next takes over. Returns when none is left.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", test))]
|
||||
fn follow<D: ReportSource>(mut candidates: Vec<Candidate<D>>, now: impl Fn() -> Instant, mut emit: impl FnMut(Followed<'_>)) {
|
||||
let Some(first) = candidates.first() else { return };
|
||||
emit(Followed::Device(first.ids, None));
|
||||
let mut followed = 0;
|
||||
// When the followed device last showed use.
|
||||
let mut used_at: Option<Instant> = None;
|
||||
// The buttons each candidate last reported down, by index, as long as it has reported them.
|
||||
let mut buttons: Vec<Option<u32>> = candidates.iter().map(|_| None).collect();
|
||||
let mut report = [0_u8; REPORT_BUFFER_BYTES];
|
||||
while !candidates.is_empty() {
|
||||
// One device is waited on as before; several take turns, briefly, so none waits behind
|
||||
// an idle one. Reports queue in the system meanwhile, so none is lost.
|
||||
let timeout_ms = if candidates.len() == 1 { 100 } else { 10 };
|
||||
let mut index = 0;
|
||||
while let Some(candidate) = candidates.get(index) {
|
||||
match candidate.device.read_report(&mut report, timeout_ms) {
|
||||
Ok(0) => {}
|
||||
Ok(size) => {
|
||||
let bytes = report.get(..size).unwrap_or_default();
|
||||
let before = buttons.get(index).copied().flatten();
|
||||
if let (Some(seen), Some(down)) = (buttons.get_mut(index), candidate.layout.decode(bytes).and_then(|decoded| decoded.buttons)) {
|
||||
*seen = Some(down);
|
||||
}
|
||||
if shows_use(candidate.layout, bytes) {
|
||||
let at = now();
|
||||
if index != followed && used_at.is_none_or(|used| at.duration_since(used) >= STALE_AFTER) {
|
||||
let (vendor, product) = candidate.ids;
|
||||
log::info!("3D mouse: reading {vendor:04x}:{product:04x}, the device in use");
|
||||
followed = index;
|
||||
emit(Followed::Device(candidate.ids, before));
|
||||
}
|
||||
if index == followed {
|
||||
used_at = Some(at);
|
||||
}
|
||||
}
|
||||
if index == followed {
|
||||
emit(Followed::Report(&candidate.layout, bytes));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
candidates.remove(index);
|
||||
if index < buttons.len() {
|
||||
buttons.remove(index);
|
||||
}
|
||||
if index == followed {
|
||||
emit(Followed::Gone);
|
||||
followed = 0;
|
||||
used_at = None;
|
||||
if let Some(next) = candidates.first() {
|
||||
emit(Followed::Device(next.ids, buttons.first().copied().flatten()));
|
||||
}
|
||||
} else if index < followed {
|
||||
followed -= 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
let Ok(mut api) = hid_api() else {
|
||||
@@ -528,36 +641,38 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
if let Some((device, layout, ids)) = candidates(&mut api).into_iter().find_map(|path| {
|
||||
let device = api
|
||||
.open_path(&path)
|
||||
.inspect_err(|error| {
|
||||
// On macOS: missing Input Monitoring permission, or the 3DxWare helper holding the device.
|
||||
// The driver having the device is the reason it cannot be opened, not a problem.
|
||||
if !DRIVER_CONNECTED.load(Ordering::Relaxed) && !DRIVER_REGISTERED.load(Ordering::Relaxed) {
|
||||
log::warn!("3D mouse: cannot open the device: {error}");
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
// Read when the device was opened, from this device alone.
|
||||
let info = device.get_device_info().ok()?;
|
||||
let layout = device_layout(&device, info.vendor_id())?;
|
||||
Some((device, layout, (info.vendor_id(), info.product_id())))
|
||||
}) {
|
||||
connected(&ctx, &shared, Source::Hid(Some(ids)));
|
||||
let mut report = [0_u8; REPORT_BUFFER_BYTES];
|
||||
loop {
|
||||
match device.read_timeout(&mut report, 100) {
|
||||
Ok(0) => {}
|
||||
Ok(size) => publish(&ctx, &shared, layout, report.get(..size).unwrap_or_default()),
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
let open: Vec<_> = candidates(&mut api)
|
||||
.into_iter()
|
||||
.filter_map(|path| {
|
||||
let device = api
|
||||
.open_path(&path)
|
||||
.inspect_err(|error| {
|
||||
// On macOS: missing Input Monitoring permission, or the 3DxWare helper holding the device.
|
||||
// The driver having the device is the reason it cannot be opened, not a problem.
|
||||
if !DRIVER_CONNECTED.load(Ordering::Relaxed) && !DRIVER_REGISTERED.load(Ordering::Relaxed) {
|
||||
log::warn!("3D mouse: cannot open the device: {error}");
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
// Read when the device was opened, from this device alone.
|
||||
let info = device.get_device_info().ok()?;
|
||||
let layout = device_layout(&device, info.vendor_id())?;
|
||||
Some(Candidate { device, layout, ids: (info.vendor_id(), info.product_id()) })
|
||||
})
|
||||
.collect();
|
||||
follow(open, Instant::now, |event| match event {
|
||||
Followed::Device(ids, buttons) => {
|
||||
connected(&ctx, &shared, Source::Hid(Some(ids)));
|
||||
// Taken over from another device: what it reported meanwhile is its baseline.
|
||||
if let Some(buttons) = buttons
|
||||
&& let Ok(mut reading) = shared.lock()
|
||||
{
|
||||
reading.set_buttons(buttons);
|
||||
}
|
||||
}
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
Followed::Report(layout, report) => publish(&ctx, &shared, *layout, report),
|
||||
Followed::Gone => reset(&ctx, &shared),
|
||||
});
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
@@ -1294,6 +1409,174 @@ mod tests {
|
||||
assert!(shared.lock().unwrap().arrived, "but the report did arrive");
|
||||
}
|
||||
|
||||
/// A simulated 3D-mouse interface. Each read takes the next scripted step, a report or a
|
||||
/// timeout (empty), and sets the shared clock to the step's time. Once the script runs out,
|
||||
/// the device is unplugged.
|
||||
struct Simulated<'a> {
|
||||
clock: &'a std::cell::Cell<Instant>,
|
||||
start: Instant,
|
||||
steps: std::cell::RefCell<std::collections::VecDeque<(u64, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
impl ReportSource for Simulated<'_> {
|
||||
fn read_report(&self, report: &mut [u8], _timeout_ms: i32) -> Result<usize, String> {
|
||||
let (ms, bytes) = self.steps.borrow_mut().pop_front().ok_or_else(|| "unplugged".to_string())?;
|
||||
self.clock.set(self.start + Duration::from_millis(ms));
|
||||
report.get_mut(..bytes.len()).ok_or_else(|| "report too long".to_string())?.copy_from_slice(&bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Seen {
|
||||
/// The product id now read.
|
||||
Device(u16),
|
||||
/// The buttons it had reported down before it was read, its baseline.
|
||||
Baseline(u32),
|
||||
/// A report published, the product id read when it was, and whether it showed use.
|
||||
Report(u16, Vec<u8>, bool),
|
||||
Gone,
|
||||
}
|
||||
|
||||
/// Follows simulated devices, given as (product id, scripted steps in ms), and returns what
|
||||
/// the reader was told.
|
||||
fn follow_simulated(devices: &[(u16, &[(u64, &[u8])])]) -> Vec<Seen> {
|
||||
follow_simulated_with(Layout::default(), devices)
|
||||
}
|
||||
|
||||
/// As `follow_simulated`, with every device's reports laid out as `layout`.
|
||||
fn follow_simulated_with(layout: Layout, devices: &[(u16, &[(u64, &[u8])])]) -> Vec<Seen> {
|
||||
let start = Instant::now();
|
||||
let clock = std::cell::Cell::new(start);
|
||||
let candidates = devices
|
||||
.iter()
|
||||
.map(|(product, steps)| Candidate {
|
||||
device: Simulated {
|
||||
clock: &clock,
|
||||
start,
|
||||
steps: std::cell::RefCell::new(steps.iter().map(|(ms, bytes)| (*ms, bytes.to_vec())).collect()),
|
||||
},
|
||||
layout,
|
||||
ids: (0x256f, *product),
|
||||
})
|
||||
.collect();
|
||||
let (mut seen, mut read) = (Vec::new(), 0);
|
||||
follow(
|
||||
candidates,
|
||||
|| clock.get(),
|
||||
|event| match event {
|
||||
Followed::Device((_, product), buttons) => {
|
||||
read = product;
|
||||
seen.push(Seen::Device(product));
|
||||
seen.extend(buttons.map(Seen::Baseline));
|
||||
}
|
||||
Followed::Report(layout, bytes) => seen.push(Seen::Report(read, bytes.to_vec(), shows_use(*layout, bytes))),
|
||||
Followed::Gone => seen.push(Seen::Gone),
|
||||
},
|
||||
);
|
||||
seen
|
||||
}
|
||||
|
||||
const RECEIVER: u16 = 0xc652;
|
||||
const SPACEMOUSE: u16 = 0xc63a;
|
||||
const PUSH_X: &[u8] = &[1, 100, 0, 0, 0, 0, 0];
|
||||
const AT_REST: &[u8] = &[1, 0, 0, 0, 0, 0, 0];
|
||||
const TILT: &[u8] = &[2, 0, 0, 50, 0, 0, 0];
|
||||
|
||||
/// #3988: a Universal Receiver with nothing paired is found first and never fails, and a wired
|
||||
/// SpaceMouse next to it was never read. The receiver is followed only until the SpaceMouse
|
||||
/// moves; the SpaceMouse's reports, its zeros included, are read from then on.
|
||||
#[test]
|
||||
fn an_idle_receiver_does_not_keep_a_spacemouse_from_being_read() {
|
||||
let receiver: &[(u64, &[u8])] = &[(0, &[]), (10, &[]), (20, &[]), (30, &[])];
|
||||
let spacemouse: &[(u64, &[u8])] = &[(5, &[]), (15, PUSH_X), (25, AT_REST), (35, TILT)];
|
||||
assert_eq!(
|
||||
follow_simulated(&[(RECEIVER, receiver), (SPACEMOUSE, spacemouse)]),
|
||||
[
|
||||
Seen::Device(RECEIVER),
|
||||
Seen::Device(SPACEMOUSE),
|
||||
Seen::Report(SPACEMOUSE, PUSH_X.to_vec(), true),
|
||||
Seen::Report(SPACEMOUSE, AT_REST.to_vec(), false),
|
||||
Seen::Report(SPACEMOUSE, TILT.to_vec(), true),
|
||||
// The receiver is unplugged first, then the SpaceMouse, the one being read.
|
||||
Seen::Gone,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Two devices in use at once don't trade the reading: the other takes over only after the
|
||||
/// followed one has shown no use for `STALE_AFTER`. A report at rest or with an id the layout
|
||||
/// doesn't know shows no use, and only the followed device's reports are published.
|
||||
#[test]
|
||||
fn another_device_takes_over_only_once_the_followed_one_is_still() {
|
||||
let unknown_id: &[u8] = &[0x17, 80];
|
||||
let first: &[(u64, &[u8])] = &[(0, PUSH_X), (100, AT_REST), (200, &[])];
|
||||
let second: &[(u64, &[u8])] = &[(50, PUSH_X), (150, unknown_id), (250, TILT)];
|
||||
assert_eq!(
|
||||
follow_simulated(&[(0xc635, first), (SPACEMOUSE, second)]),
|
||||
[
|
||||
Seen::Device(0xc635),
|
||||
Seen::Report(0xc635, PUSH_X.to_vec(), true),
|
||||
// The second moves 50 ms after the first did: not yet. The first's own rest
|
||||
// report is read, and the second's unknown report is not.
|
||||
Seen::Report(0xc635, AT_REST.to_vec(), false),
|
||||
// 250 ms after the first last moved, the second takes over.
|
||||
Seen::Device(SPACEMOUSE),
|
||||
Seen::Report(SPACEMOUSE, TILT.to_vec(), true),
|
||||
Seen::Gone,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// One device is read as before: every report, until it fails. When the followed one of
|
||||
/// several fails, the reading is reset and the next one is read.
|
||||
#[test]
|
||||
fn a_device_that_fails_hands_the_reading_to_the_next() {
|
||||
let only: &[(u64, &[u8])] = &[(0, &[]), (1, AT_REST), (2, &[0x17, 80])];
|
||||
assert_eq!(
|
||||
follow_simulated(&[(SPACEMOUSE, only)]),
|
||||
[
|
||||
Seen::Device(SPACEMOUSE),
|
||||
Seen::Report(SPACEMOUSE, AT_REST.to_vec(), false),
|
||||
Seen::Report(SPACEMOUSE, vec![0x17, 80], false),
|
||||
Seen::Gone
|
||||
]
|
||||
);
|
||||
let unplugged: &[(u64, &[u8])] = &[];
|
||||
let next: &[(u64, &[u8])] = &[(1, PUSH_X)];
|
||||
assert_eq!(
|
||||
follow_simulated(&[(SPACEMOUSE, unplugged), (RECEIVER, next)]),
|
||||
[Seen::Device(SPACEMOUSE), Seen::Gone, Seen::Device(RECEIVER), Seen::Report(RECEIVER, PUSH_X.to_vec(), true), Seen::Gone]
|
||||
);
|
||||
assert_eq!(follow_simulated(&[]), []);
|
||||
}
|
||||
|
||||
/// A button press that makes a device the one in use is a press. The buttons it reported while
|
||||
/// another was read are its baseline; the reader keeps them, so the press isn't taken for a
|
||||
/// button already held, as a device's first button report is.
|
||||
#[test]
|
||||
fn a_press_that_takes_over_counts_as_a_press() {
|
||||
let mut layout = Layout::default();
|
||||
layout.buttons[0] =
|
||||
Some(solidcraft_space_mouse::Field { report_id: 3, bit_offset: 0, bits: 1, logical_min: 0, logical_max: 1, relative: false });
|
||||
let (none_down, first_down): (&[u8], &[u8]) = (&[3, 0], &[3, 1]);
|
||||
let receiver: &[(u64, &[u8])] = &[(0, &[]), (10, &[]), (20, &[])];
|
||||
let spacemouse: &[(u64, &[u8])] = &[(5, none_down), (15, first_down), (25, &[])];
|
||||
assert_eq!(
|
||||
follow_simulated_with(layout, &[(RECEIVER, receiver), (SPACEMOUSE, spacemouse)]),
|
||||
[Seen::Device(RECEIVER), Seen::Device(SPACEMOUSE), Seen::Baseline(0), Seen::Report(SPACEMOUSE, first_down.to_vec(), true), Seen::Gone,]
|
||||
);
|
||||
// What the reader does with that: the press after the baseline is a tap.
|
||||
let mut reading = Reading { buttons_known: false, ..Reading::new(Instant::now()) };
|
||||
reading.set_buttons(0);
|
||||
assert!(decode_report(first_down, layout, &mut reading, Instant::now()));
|
||||
assert_eq!((reading.buttons, reading.taps), (1, 1));
|
||||
// Without one, the same report is the baseline: held, not pressed.
|
||||
let mut unseen = Reading { buttons_known: false, ..Reading::new(Instant::now()) };
|
||||
assert!(decode_report(first_down, layout, &mut unseen, Instant::now()));
|
||||
assert_eq!((unseen.buttons, unseen.taps), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_reading_stays_until_the_next_one_and_a_hid_reading_goes_stale() {
|
||||
let now = Instant::now();
|
||||
|
||||
@@ -208,6 +208,29 @@ pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
|
||||
adapters.iter().any(|a| a.get_info().device_type != eframe::wgpu::DeviceType::Cpu)
|
||||
}
|
||||
|
||||
/// The environment variable that switches wgpu's indirect-call validation on or off.
|
||||
pub const INDIRECT_VALIDATION_ENV: &str = "WGPU_VALIDATION_INDIRECT_CALL";
|
||||
|
||||
/// The instance flags for the window. wgpu's indirect-call validation compiles a compute shader
|
||||
/// when the device is created, and on Intel Macs (macOS 12 with Intel Iris graphics, #3024,
|
||||
/// #3080, #4605) the Metal compiler refuses it: the device is lost and the window never opens.
|
||||
/// So on an Intel Mac the validation is left off, unless the user turned it on or off with
|
||||
/// `WGPU_VALIDATION_INDIRECT_CALL` themselves.
|
||||
pub fn window_instance_flags(flags: eframe::wgpu::InstanceFlags, intel_mac: bool, env: Option<&str>) -> eframe::wgpu::InstanceFlags {
|
||||
if intel_mac && env.map(str::trim).is_none_or(str::is_empty) { flags - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL } else { flags }
|
||||
}
|
||||
|
||||
/// Windows with OpenGL: the window keeps the system title bar (#2063). The window without OS
|
||||
/// decorations keeps its caption style while having no frame, and some OpenGL drivers (older
|
||||
/// Intel HD/UHD) then show the picture a caption's height lower than the window, so it no longer
|
||||
/// lines up with the pointer. Direct3D 12 and Vulkan draw it in place and keep the application bar
|
||||
/// as the title bar. `backends` are the ones wgpu starts: OpenGL alone is decided before the
|
||||
/// window opens.
|
||||
pub fn system_title_bar(windows: bool, backends: eframe::wgpu::Backends) -> bool {
|
||||
use eframe::wgpu::Backends;
|
||||
windows && backends.contains(Backends::GL) && !backends.intersects(Backends::DX12 | Backends::VULKAN)
|
||||
}
|
||||
|
||||
/// 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());
|
||||
@@ -246,6 +269,19 @@ mod tests {
|
||||
assert!(settings_directory(Some(file.as_os_str()), Some(default)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intel_macs_start_without_indirect_call_validation() {
|
||||
use eframe::wgpu::InstanceFlags as F;
|
||||
let on = F::VALIDATION | F::VALIDATION_INDIRECT_CALL;
|
||||
assert_eq!(window_instance_flags(on, true, None), F::VALIDATION);
|
||||
assert_eq!(window_instance_flags(on, true, Some(" ")), F::VALIDATION);
|
||||
// The user's own choice stands, on or off.
|
||||
assert_eq!(window_instance_flags(on, true, Some("1")), on);
|
||||
assert_eq!(window_instance_flags(F::VALIDATION, true, Some("0")), F::VALIDATION);
|
||||
// Every other machine keeps what it had.
|
||||
assert_eq!(window_instance_flags(on, false, None), on);
|
||||
}
|
||||
|
||||
#[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")));
|
||||
@@ -322,6 +358,18 @@ mod tests {
|
||||
assert_eq!(choose_backends(true, false, false, Some(""), probe(&[Backends::GL])), Some(Backends::GL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_opengl_on_windows_gets_the_system_title_bar() {
|
||||
use eframe::wgpu::Backends;
|
||||
assert!(system_title_bar(true, Backends::GL));
|
||||
for b in [Backends::DX12, Backends::VULKAN, Backends::DX12 | Backends::GL, Backends::VULKAN | Backends::GL, Backends::all()] {
|
||||
assert!(!system_title_bar(true, b), "{b:?}");
|
||||
}
|
||||
for b in [Backends::GL, Backends::VULKAN, Backends::METAL] {
|
||||
assert!(!system_title_bar(false, b), "{b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgpu_backend_the_preference_and_the_software_renderer_override_the_order() {
|
||||
use eframe::wgpu::Backends;
|
||||
|
||||
@@ -148,6 +148,7 @@ origin false
|
||||
pan false
|
||||
params false
|
||||
paste false
|
||||
path_flange false
|
||||
pattern_circ false
|
||||
pattern_path false
|
||||
pattern_rect false
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Path Flange</title>
|
||||
<path d="M9.5 44L23.5 44A6 6 0 0 0 29.5 38L29.5 26A6 6 0 0 1 35.5 20L43.5 20A6 6 0 0 1 47.74 21.76L54.81 28.83" fill="none" stroke="#34465b" stroke-width="11.4" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M9.5 44L23.5 44" fill="none" stroke="#b8c3cf" stroke-width="6.3" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M23.5 44A6 6 0 0 0 29.5 38L29.5 26A6 6 0 0 1 35.5 20L43.5 20A6 6 0 0 1 47.74 21.76L54.81 28.83" fill="none" stroke="#328bd3" stroke-width="6.3" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 638 B |
+13
-1
@@ -335,6 +335,7 @@
|
||||
"Show Agent Cursor": "Zobrazit kurzor agenta",
|
||||
"Show in folder": "Zobrazit ve složce",
|
||||
"Show the pointer an AI agent moves (#35)": "Zobrazit ukazatel, kterým pohybuje AI agent (#35)",
|
||||
"Show tool at cursor": "Zobrazit nástroj u kurzoru",
|
||||
"Signed axes": "Osy se znaménkem",
|
||||
"Size": "Velikost",
|
||||
"Sketch constraints": "Vazby náčrtu",
|
||||
@@ -998,6 +999,16 @@
|
||||
"pattern.direction_1": "Směr 1",
|
||||
"pattern.direction_2": "Směr 2",
|
||||
"pattern.flip_direction": "Převrátit směr",
|
||||
"perf.click_for_details": "Klikněte pro podrobnosti: graf časů snímků, histogram a co vykreslila GPU",
|
||||
"perf.draw_calls": "Volání vykreslení",
|
||||
"perf.line_segments": "Úsečky",
|
||||
"perf.low_1": "1% minimum",
|
||||
"perf.renderer": "Vykreslování",
|
||||
"perf.scene_build": "Sestavení scény",
|
||||
"perf.triangles": "Trojúhelníky",
|
||||
"perf.ui_time": "Čas UI",
|
||||
"perf.viewport": "Pohled",
|
||||
"perf.worst": "nejhorší",
|
||||
"pick a hole to seat it, or OK places it at the origin": "vyberte otvor pro usazení, nebo OK umístí do počátku",
|
||||
"pick one or two vertices, edges or faces": "vyberte jeden nebo dva vrcholy, hrany či plochy",
|
||||
"prefs.cpu": "CPU (softwarově)",
|
||||
@@ -1222,6 +1233,7 @@
|
||||
"search.try": "Zadejte název příkazu, prvek v tomto návrhu nebo parametr. Chcete-li spustit příkaz s hodnotami, zadejte jeho id a objekt JSON: {example}",
|
||||
"search.valid": "Platné",
|
||||
"settings.display_quality_note": "Jemnější úrovně kreslí zakřivené plochy a hrany hladčeji, ale síťování trvá déle. Mění se jen zobrazení, nikdy návrh ani exporty.",
|
||||
"settings.frame_rate_details_note": "Přidá graf časů posledních snímků s vykreslenými trojúhelníky, histogram časů snímků a to, co vykreslila GPU. Kliknutím na snímkovou frekvenci v pohledu je zobrazíte nebo skryjete.",
|
||||
"settings.frame_rate_note": "Zobrazuje snímky za sekundu v pravém dolním rohu pohledu.",
|
||||
"settings.measure_axis_distances": "Zobrazit vzdálenosti X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Při otevřeném měření zobrazit kromě celkové vzdálenosti i složky ve směru os.",
|
||||
@@ -1571,7 +1583,6 @@
|
||||
"ui.ai_permissions": "Oprávnění",
|
||||
"ui.ai_preset": "Předvolba",
|
||||
"ui.ai_remove": "Odebrat",
|
||||
"ui.ai_request_limit": "zastaveno po 24 požadavcích na jednu zprávu; pro pokračování pošlete další zprávu",
|
||||
"ui.ai_reset": "Obnovit",
|
||||
"ui.ai_settings": "Nastavení AI…",
|
||||
"ui.ai_show_features": "Zobrazit funkce AI",
|
||||
@@ -1755,6 +1766,7 @@
|
||||
"ui.flip": "Převrátit",
|
||||
"ui.format": "Formát",
|
||||
"ui.frame_rate": "Snímková frekvence",
|
||||
"ui.frame_rate_details": "Podrobnosti snímkové frekvence",
|
||||
"ui.front": "Zepředu",
|
||||
"ui.fully_constrained": "Plně zavazbeno",
|
||||
"ui.function": "Funkce",
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
"Show Agent Cursor": "Vis agentmarkør",
|
||||
"Show in folder": "Vis i mappe",
|
||||
"Show the pointer an AI agent moves (#35)": "Vis markøren, som en AI-agent bevæger (#35)",
|
||||
"Show tool at cursor": "Vis værktøj ved markøren",
|
||||
"Signed axes": "Akser med fortegn",
|
||||
"Size": "Størrelse",
|
||||
"Sketch constraints": "Skitsebindinger",
|
||||
@@ -1415,7 +1416,6 @@
|
||||
"ui.ai_permissions": "Tilladelser",
|
||||
"ui.ai_preset": "Forudindstilling",
|
||||
"ui.ai_remove": "Fjern",
|
||||
"ui.ai_request_limit": "stoppet efter 24 anmodninger for én besked; send en ny besked for at fortsætte",
|
||||
"ui.ai_reset": "Nulstil",
|
||||
"ui.ai_settings": "AI-indstillinger…",
|
||||
"ui.ai_show_features": "Vis AI-funktioner",
|
||||
|
||||
@@ -421,6 +421,7 @@
|
||||
"Show Agent Cursor": "Agenten-Cursor anzeigen",
|
||||
"Show in folder": "Im Ordner anzeigen",
|
||||
"Show the pointer an AI agent moves (#35)": "Den Zeiger anzeigen, den ein KI-Agent bewegt (#35)",
|
||||
"Show tool at cursor": "Werkzeug am Cursor anzeigen",
|
||||
"Signed axes": "Vorzeichenbehaftete Achsen",
|
||||
"Size": "Größe",
|
||||
"Sketch constraints": "Skizzenbedingungen",
|
||||
@@ -1362,6 +1363,7 @@
|
||||
"thread.designation": "Bezeichnung",
|
||||
"thread.iso_metric": "ISO metrisch",
|
||||
"thread.select_size": "Größe wählen",
|
||||
"timeline.feature_hint": "Doppelklicken zum Bearbeiten · Ziehen zum Neuordnen · Rechtsklicken für weitere Optionen",
|
||||
"timeline.group": "Neuen Merkmalsordner anlegen",
|
||||
"timeline.move_many": "Zeitleistenelemente verschieben",
|
||||
"timeline.move_to_group": "Merkmale in Ordner verschieben",
|
||||
@@ -1666,7 +1668,6 @@
|
||||
"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",
|
||||
|
||||
+64
-1
@@ -8,6 +8,7 @@
|
||||
"A Single hole snaps to the middle of its face and to the centres of its round edges": "A Single hole snaps to the middle of its face and to the centres of its round edges",
|
||||
"A new configuration, a copy of the active one": "A new configuration, a copy of the active one",
|
||||
"A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out": "A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out",
|
||||
"A short title for the problem (optional)": "A short title for the problem (optional)",
|
||||
"ARRANGE": "ARRANGE",
|
||||
"About SolidCraft": "About SolidCraft",
|
||||
"Acceleration": "Acceleration",
|
||||
@@ -21,6 +22,7 @@
|
||||
"Add Mid Radius": "Add Mid Radius",
|
||||
"Add Row": "Add Row",
|
||||
"Add a tool": "Add a tool",
|
||||
"Add bend": "Add bend",
|
||||
"Add condition": "Add condition",
|
||||
"Add or edit rule": "Add or edit rule",
|
||||
"Add to New Folder": "Add to New Folder",
|
||||
@@ -35,7 +37,9 @@
|
||||
"Also deletes": "Also deletes",
|
||||
"Anchor": "Anchor",
|
||||
"Angle 2": "Angle 2",
|
||||
"Angle Display": "Angle Display",
|
||||
"Animate": "Animate",
|
||||
"Another bend on the far edge of the last flange": "Another bend on the far edge of the last flange",
|
||||
"App/system information and command settings are included. Review private details before sharing.": "App/system information and command settings are included. Review private details before sharing.",
|
||||
"Appearance name": "Appearance name",
|
||||
"Applied Radius": "Applied Radius",
|
||||
@@ -52,6 +56,7 @@
|
||||
"BOUNDARY SURFACE": "BOUNDARY SURFACE",
|
||||
"BROWSER": "BROWSER",
|
||||
"Bar": "Bar",
|
||||
"Bend": "Bend",
|
||||
"Bend Edges": "Bend Edges",
|
||||
"Bend Line Position": "Bend Line Position",
|
||||
"Bend Position": "Bend Position",
|
||||
@@ -218,6 +223,7 @@
|
||||
"Fit and G2 corners require Constant radius.": "Fit and G2 corners require Constant radius.",
|
||||
"Fixed Face": "Fixed Face",
|
||||
"Flange thickness": "Flange thickness",
|
||||
"Flanges": "Flanges",
|
||||
"Flat Pattern": "Flat Pattern",
|
||||
"Flat size": "Flat size",
|
||||
"Follow camera (Look At sketches)": "Follow camera (Look At sketches)",
|
||||
@@ -227,6 +233,8 @@
|
||||
"From Bodies": "From Bodies",
|
||||
"From edges": "From edges",
|
||||
"Full Length": "Full Length",
|
||||
"Full thread depth": "Full thread depth",
|
||||
"Further Bends": "Further Bends",
|
||||
"G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet.": "G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet.",
|
||||
"Gap": "Gap",
|
||||
"Grid": "Grid",
|
||||
@@ -247,6 +255,7 @@
|
||||
"In This Design": "In This Design",
|
||||
"In place (surfaces)": "In place (surfaces)",
|
||||
"In plane": "In plane",
|
||||
"Included": "Included",
|
||||
"Inside": "Inside",
|
||||
"Instant": "Instant",
|
||||
"Interface font": "Interface font",
|
||||
@@ -305,6 +314,7 @@
|
||||
"Mitre": "Mitre",
|
||||
"Model Parameters": "Model Parameters",
|
||||
"Modeled": "Modeled",
|
||||
"More themes": "More themes",
|
||||
"More toolbar panels": "More toolbar panels",
|
||||
"Motion": "Motion",
|
||||
"Mouse gestures": "Mouse gestures",
|
||||
@@ -332,6 +342,7 @@
|
||||
"Nominal diameter": "Nominal diameter",
|
||||
"None": "None",
|
||||
"Not available yet": "Not available yet",
|
||||
"Not saved yet": "Not saved yet",
|
||||
"Notch": "Notch",
|
||||
"OUTPUT": "OUTPUT",
|
||||
"Of": "Of",
|
||||
@@ -347,6 +358,7 @@
|
||||
"Orbit (right drag)": "Orbit (right drag)",
|
||||
"Other side": "Other side",
|
||||
"Other surface (mutual trim)": "Other surface (mutual trim)",
|
||||
"Other way": "Other way",
|
||||
"Outer radius": "Outer radius",
|
||||
"Outside": "Outside",
|
||||
"Overall length": "Overall length",
|
||||
@@ -376,6 +388,7 @@
|
||||
"Preview": "Preview",
|
||||
"Preview Row": "Preview Row",
|
||||
"Preview leaves the current design unchanged.": "Preview leaves the current design unchanged.",
|
||||
"Previous themes": "Previous themes",
|
||||
"Profile / Edges": "Profile / Edges",
|
||||
"Profiles or curves": "Profiles or curves",
|
||||
"Profiles/Curves": "Profiles/Curves",
|
||||
@@ -402,6 +415,7 @@
|
||||
"Remove screenshot": "Remove screenshot",
|
||||
"Remove the column": "Remove the column",
|
||||
"Remove the track": "Remove the track",
|
||||
"Remove this bend": "Remove this bend",
|
||||
"Remove this point": "Remove this point",
|
||||
"Rename Feature": "Rename Feature",
|
||||
"Rename Folder": "Rename Folder",
|
||||
@@ -443,6 +457,7 @@
|
||||
"Rules run from top to bottom. Matched drives change effective values.": "Rules run from top to bottom. Matched drives change effective values.",
|
||||
"SECTION": "SECTION",
|
||||
"SURFACE QUALITY": "SURFACE QUALITY",
|
||||
"Same way": "Same way",
|
||||
"Save Rule": "Save Rule",
|
||||
"Save View": "Save View",
|
||||
"Save screenshot…": "Save screenshot…",
|
||||
@@ -471,8 +486,10 @@
|
||||
"Shape": "Shape",
|
||||
"Sheet Rules": "Sheet Rules",
|
||||
"Show Agent Cursor": "Show Agent Cursor",
|
||||
"Show ghost": "Show ghost",
|
||||
"Show in folder": "Show in folder",
|
||||
"Show the pointer an AI agent moves (#35)": "Show the pointer an AI agent moves (#35)",
|
||||
"Show tool at cursor": "Show tool at cursor",
|
||||
"Signed axes": "Signed axes",
|
||||
"Size": "Size",
|
||||
"Sketch constraints": "Sketch constraints",
|
||||
@@ -529,6 +546,7 @@
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "This child is shared. Position changes apply to every copy of its parent.",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.",
|
||||
"This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.",
|
||||
"Thread depth": "Thread depth",
|
||||
"Thread diameter": "Thread diameter",
|
||||
"To": "To",
|
||||
"To Offset": "To Offset",
|
||||
@@ -588,10 +606,13 @@
|
||||
"Z angle": "Z angle",
|
||||
"Z distance": "Z distance",
|
||||
"Zebra": "Zebra",
|
||||
"ai.connected_toast": "SolidCraft is now available in {clients}. Restart them to use it (Settings > Assistant).",
|
||||
"ai.prefs.allow_control": "Let AI agents connect (control channel / MCP)",
|
||||
"ai.prefs.allow_control_note": "Opens a control channel on this computer only (127.0.0.1, port 27878 or the next free one). A program that connects must also show a secret token that only your user account can read, made fresh at every start. Takes effect the next time SolidCraft starts. Off: nothing listens unless you start SolidCraft with --control.",
|
||||
"ai.prefs.availability": "Assistant controls",
|
||||
"ai.prefs.background_commands": "Run agent commands in the background",
|
||||
"ai.prefs.connect_apps": "Make SolidCraft available to AI apps on this computer",
|
||||
"ai.prefs.connect_apps_note": "When SolidCraft starts, adds it as an MCP server to the AI apps found on this computer (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), so an agent can use it without any setup. Only the solidcraft entry is added or its path refreshed; an entry you changed is left alone, and the file is backed up first. Off: the entries it added are removed. Help > Connect AI assistants… does this by hand.",
|
||||
"ai.prefs.follow_camera": "Turn the camera to sketches",
|
||||
"ai.prefs.follow_camera_off": "Automatic camera following is off.",
|
||||
"ai.prefs.follow_camera_on": "The camera turns to face sketches the agent creates.",
|
||||
@@ -756,6 +777,7 @@
|
||||
"cmd.inspect_interference": "Interference",
|
||||
"cmd.inspect_isocurve": "Isocurve Analysis",
|
||||
"cmd.inspect_minimum_radius": "Minimum Radius Analysis",
|
||||
"cmd.inspect_parameter_sweep": "Parameter Sweep",
|
||||
"cmd.inspect_silhouette": "Silhouette",
|
||||
"cmd.inspect_zebra": "Zebra Analysis",
|
||||
"cmd.joint_as_built": "As-Built Joint",
|
||||
@@ -881,6 +903,7 @@
|
||||
"cmd.sheet_fold": "Fold",
|
||||
"cmd.sheet_hem": "Hem",
|
||||
"cmd.sheet_manage_rules": "Sheet Metal Rules",
|
||||
"cmd.sheet_path_flange": "Path Flange",
|
||||
"cmd.sheet_perforate": "Perforate",
|
||||
"cmd.sheet_refold": "Refold",
|
||||
"cmd.sheet_rules": "List Sheet Metal Rules",
|
||||
@@ -1073,7 +1096,18 @@
|
||||
"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.",
|
||||
"dialog.selection_step": "{step} of {total}: Select {input}",
|
||||
"dialog.step": "Step",
|
||||
"document.changed_on_disk": "{name} changed on disk",
|
||||
"document.changed_on_disk_detail": "{name} was changed by another program or computer since you opened or last saved it. Overwrite replaces those changes with yours; Save a Copy keeps both.",
|
||||
"document.copy_name": "{name} copy",
|
||||
"document.open_anyway": "Open Anyway",
|
||||
"document.open_elsewhere": "{name} is already open",
|
||||
"document.open_elsewhere_detail": "{name} is open in SolidCraft {app} on {who}, opened {when}. If you both save, one of you loses work. Open it read-only to look at it without saving over it.",
|
||||
"document.open_read_only": "Open Read-Only",
|
||||
"document.opened_read_only": "Opened read-only: Save asks for a new file",
|
||||
"document.overwrite": "Overwrite",
|
||||
"document.read_only_save": "This design was opened read-only: save it as a new file",
|
||||
"document.save_changes": "Save changes to {name}?",
|
||||
"document.save_copy": "Save a Copy…",
|
||||
"document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.",
|
||||
"drive.stopped_by_contact": "Stopped at {value}: {a} would pass through {b}",
|
||||
"drive.stopped_by_loop": "Stopped at {value}: the loop through {joint} can't close any further",
|
||||
@@ -1109,17 +1143,25 @@
|
||||
"help.apache": "Apache licence 2.0",
|
||||
"help.attributions": "Attributions (ATTRIBUTION.md)",
|
||||
"help.build": "Version {version} · commit {commit} · built {date}",
|
||||
"help.connect_note": "Restart each assistant to load the SolidCraft server. Each file was backed up as .bak before it changed.",
|
||||
"help.connect_title": "Connect AI assistants",
|
||||
"help.font_details": "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and the MIT/Bitstream Vera licence; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans (SIL OFL 1.1). The Crafting Apps' optional font pack, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), is not bundled in SolidCraft builds.",
|
||||
"help.fonts": "Fonts",
|
||||
"help.licences": "Licences",
|
||||
"help.mit": "MIT licence",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "Cuts the selected edges with a flat bevel of the distance you set. Use it to break sharp edges or to ease the entry of a pin or screw.",
|
||||
"help.solid.combine": "Joins bodies, cuts them out of a target body, or keeps only the volume they share, and can keep the tool bodies. Use it to merge parts, to cut one shape with another or to find where two bodies overlap.",
|
||||
"help.solid.extrude": "Pushes a sketch profile into 3D along a distance, adding or removing material. Use it to turn a flat outline into a block, a boss or a pocket.",
|
||||
"help.solid.fillet": "Rounds the selected edges with a smooth circular blend of the radius you set. Use it to soften sharp edges for strength, comfort or looks.",
|
||||
"help.solid.hole": "Drills a round hole of the diameter you set at a chosen point on a face. Use it for screw clearances, pins and other simple round openings.",
|
||||
"help.solid.loft": "Blends a smooth solid between two or more sketch profiles, or from a profile to a point. Use it for transitions, funnels, handles and any shape that changes section along its length.",
|
||||
"help.solid.mirror": "Makes a mirrored copy of the selected bodies or features across a plane, and can merge it with the original. Use it for symmetric parts, so that you model one half and get both.",
|
||||
"help.solid.pattern.circular": "Repeats the selected objects around an axis, for the count and total angle you set. Use it for bolt circles, spokes, fins and teeth.",
|
||||
"help.solid.pattern.rectangular": "Repeats the selected objects in one or two directions with the count and spacing you set. Use it for rows and grids of holes, ribs, pins or feet.",
|
||||
"help.solid.revolve": "Spins a sketch profile around an axis to make a round solid. Use it for shafts, knobs, bowls and any part that is round about a centre line.",
|
||||
"help.solid.shell": "Hollows out a solid, leaving walls of the thickness you set, and can open the faces you pick. Use it for boxes, covers and other thin-walled parts.",
|
||||
"help.solid.sweep": "Drags a sketch profile along a path to make a solid. Use it for pipes, rails, handles and any part of constant section that follows a curve.",
|
||||
"help.third_party": "Third-party licences",
|
||||
"home.back": "Back to {name}",
|
||||
"home.crashes": "Crash reports",
|
||||
@@ -1301,8 +1343,19 @@
|
||||
"pattern.direction_1": "Direction 1",
|
||||
"pattern.direction_2": "Direction 2",
|
||||
"pattern.flip_direction": "Flip direction",
|
||||
"perf.click_for_details": "Click for details: frame-time graph, histogram and what the GPU drew",
|
||||
"perf.draw_calls": "Draw calls",
|
||||
"perf.line_segments": "Line segments",
|
||||
"perf.low_1": "1% low",
|
||||
"perf.renderer": "Renderer",
|
||||
"perf.scene_build": "Scene build",
|
||||
"perf.triangles": "Triangles",
|
||||
"perf.ui_time": "UI time",
|
||||
"perf.viewport": "Viewport",
|
||||
"perf.worst": "worst",
|
||||
"pick a hole to seat it, or OK places it at the origin": "pick a hole to seat it, or OK places it at the origin",
|
||||
"pick one or two vertices, edges or faces": "pick one or two vertices, edges or faces",
|
||||
"pick the path": "pick the path",
|
||||
"prefs.add_item": "Add the item",
|
||||
"prefs.alt_frees_manipulators": "Alt / Option also frees manipulator arrows",
|
||||
"prefs.anti_aliasing_note": "Smooths jagged edges.",
|
||||
@@ -2052,7 +2105,9 @@
|
||||
"search.try": "Type a command name, a feature in this design, or a parameter. To run a command with values, type its id and a JSON object: {example}",
|
||||
"search.valid": "Valid",
|
||||
"section.off_hint": "No plane picked: OK turns the section off.",
|
||||
"set by the sketch's lines": "set by the sketch's lines",
|
||||
"settings.display_quality_note": "Only the display. Designs and exports are not affected.",
|
||||
"settings.frame_rate_details_note": "Adds a graph of the last frames' times with the triangles drawn over it, a histogram of frame times and what the GPU drew. Click the frame rate in the view to show or hide them.",
|
||||
"settings.frame_rate_note": "Shows frames per second in the view's bottom-right corner.",
|
||||
"settings.measure_axis_distances": "Show X/Y/Z distances",
|
||||
"settings.measure_axis_distances_help": "Show world-axis component dimensions alongside the total distance while Measure is open.",
|
||||
@@ -2107,6 +2162,7 @@
|
||||
"timeline.delete_after_marker_one": "Delete 1 Feature After History Marker",
|
||||
"timeline.delete_after_marker_other": "Delete {count} Features After History Marker",
|
||||
"timeline.delete_many": "Delete {count} Selected Features",
|
||||
"timeline.feature_hint": "Double-click to edit · drag to reorder · right-click for more",
|
||||
"timeline.group": "New Feature Folder",
|
||||
"timeline.move_many": "Move Timeline Items",
|
||||
"timeline.move_to_group": "Move Features to Folder",
|
||||
@@ -2254,6 +2310,7 @@
|
||||
"ui.agent_chat_auth_title": "Sign in to the agent",
|
||||
"ui.agent_chat_cancel": "Cancel",
|
||||
"ui.agent_chat_changes": "Changes: {n}, one undo step",
|
||||
"ui.agent_chat_changes_n": "{n} changes",
|
||||
"ui.agent_chat_changes_of": "{n}/{limit} changes",
|
||||
"ui.agent_chat_close": "Close",
|
||||
"ui.agent_chat_command": "Agent command",
|
||||
@@ -2358,6 +2415,7 @@
|
||||
"ui.agent_chat_stop_tip": "Stop the agent and everything it started",
|
||||
"ui.agent_chat_stop_turn_tip": "Stop the agent (Esc). Queued messages stay queued.",
|
||||
"ui.agent_chat_turn_limit": "Changes per message",
|
||||
"ui.agent_chat_turn_unlimited": "Unlimited",
|
||||
"ui.agent_chat_undo_changes": "Undo these changes",
|
||||
"ui.agent_chat_undo_label": "Agent chat: {message}",
|
||||
"ui.agent_chat_undone": "(undone)",
|
||||
@@ -2426,7 +2484,9 @@
|
||||
"ui.ai_preset": "Preset",
|
||||
"ui.ai_protocol": "Protocol",
|
||||
"ui.ai_remove": "Remove",
|
||||
"ui.ai_request_limit": "stopped after 24 requests for one message; send another message to go on",
|
||||
"ui.ai_request_timed_out": "The model took longer than {seconds} s. You can raise the request timeout for this endpoint in Preferences › Assistant.",
|
||||
"ui.ai_request_timeout": "Request timeout",
|
||||
"ui.ai_request_timeout_hint": "How long one model request to this endpoint may take, from 30 s to an hour. Raise it for a slow local model.",
|
||||
"ui.ai_reset": "Reset",
|
||||
"ui.ai_settings": "AI settings…",
|
||||
"ui.ai_show_features": "Show AI features",
|
||||
@@ -2617,6 +2677,7 @@
|
||||
"ui.flip": "Flip",
|
||||
"ui.format": "Format",
|
||||
"ui.frame_rate": "Frame rate",
|
||||
"ui.frame_rate_details": "Frame rate details",
|
||||
"ui.front": "Front",
|
||||
"ui.fully_constrained": "Fully constrained",
|
||||
"ui.function": "Function",
|
||||
@@ -2957,6 +3018,8 @@
|
||||
"ui.start": "Start",
|
||||
"ui.start_over": "Start over",
|
||||
"ui.start_page": "Start page",
|
||||
"ui.step_values": "Step values with the arrow keys and the wheel",
|
||||
"ui.step_values_tip": "In a value field you are typing in, Up/Down change a plain number by 1 (Shift: 10, Alt: 0.1), and so does the mouse wheel over the field. Expressions are left as typed.",
|
||||
"ui.stretch": "Stretch",
|
||||
"ui.supersampling": "Supersampling (SSAA)",
|
||||
"ui.supersampling_adaptive": "Only when the view is still",
|
||||
|
||||
@@ -341,6 +341,7 @@
|
||||
"Show Agent Cursor": "Mostrar cursor del agente",
|
||||
"Show in folder": "Mostrar en carpeta",
|
||||
"Show the pointer an AI agent moves (#35)": "Mostrar el puntero que mueve un agente de IA (#35)",
|
||||
"Show tool at cursor": "Mostrar la herramienta junto al cursor",
|
||||
"Signed axes": "Ejes con signo",
|
||||
"Size": "Tamaño",
|
||||
"Sketch constraints": "Restricciones del boceto",
|
||||
@@ -2063,7 +2064,6 @@
|
||||
"ui.ai_permissions": "Permisos",
|
||||
"ui.ai_preset": "Preajuste",
|
||||
"ui.ai_remove": "Eliminar",
|
||||
"ui.ai_request_limit": "se ha detenido tras 24 solicitudes en un mensaje; envía otro para continuar",
|
||||
"ui.ai_reset": "Restablecer",
|
||||
"ui.ai_settings": "Ajustes de IA…",
|
||||
"ui.ai_show_features": "Mostrar funciones de IA",
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
"Show Agent Cursor": "Afficher le curseur de l'agent",
|
||||
"Show in folder": "Afficher dans le dossier",
|
||||
"Show the pointer an AI agent moves (#35)": "Afficher le pointeur déplacé par un agent IA (#35)",
|
||||
"Show tool at cursor": "Afficher l'outil près du curseur",
|
||||
"Signed axes": "Axes signés",
|
||||
"Size": "Taille",
|
||||
"Sketch constraints": "Contraintes d'esquisse",
|
||||
@@ -867,11 +868,17 @@
|
||||
"help.mit": "Licence MIT",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "Coupe les arêtes sélectionnées par un biseau plat de la distance indiquée. À utiliser pour casser des arêtes vives ou faciliter l'entrée d'une goupille ou d'une vis.",
|
||||
"help.solid.combine": "Joint des corps, les retire d'un corps cible ou ne garde que le volume qu'ils ont en commun, et peut conserver les corps outils. À utiliser pour fusionner des pièces, tailler une forme avec une autre ou repérer où deux corps se recouvrent.",
|
||||
"help.solid.extrude": "Pousse le profil d'une esquisse en 3D sur une distance, en ajoutant ou en retirant de la matière. À utiliser pour transformer un contour plat en bloc, en bossage ou en poche.",
|
||||
"help.solid.fillet": "Arrondit les arêtes sélectionnées par un raccord circulaire lisse du rayon indiqué. À utiliser pour adoucir des arêtes vives par souci de solidité, de confort ou d'aspect.",
|
||||
"help.solid.hole": "Perce un trou rond du diamètre indiqué en un point choisi d'une face. À utiliser pour les passages de vis, les goupilles et autres ouvertures rondes simples.",
|
||||
"help.solid.loft": "Raccorde en douceur deux profils d'esquisse ou plus, ou un profil et un point, par un solide lissé. À utiliser pour les transitions, entonnoirs, poignées et toute forme dont la section change sur sa longueur.",
|
||||
"help.solid.mirror": "Crée une copie symétrique des corps ou fonctions sélectionnés par rapport à un plan, et peut la fusionner avec l'original. À utiliser pour les pièces symétriques : on modélise une moitié et on obtient les deux.",
|
||||
"help.solid.pattern.circular": "Répète les objets sélectionnés autour d'un axe, selon le nombre et l'angle total indiqués. À utiliser pour les cercles de perçages, rayons, ailettes et dents.",
|
||||
"help.solid.pattern.rectangular": "Répète les objets sélectionnés dans une ou deux directions, selon le nombre et l'espacement indiqués. À utiliser pour les rangées et grilles de trous, nervures, goupilles ou pieds.",
|
||||
"help.solid.revolve": "Fait tourner le profil d'une esquisse autour d'un axe pour obtenir un solide de révolution. À utiliser pour les arbres, boutons, bols et toute pièce ronde autour d'une ligne centrale.",
|
||||
"help.solid.shell": "Évide un solide en laissant des parois de l'épaisseur indiquée, et peut ouvrir les faces choisies. À utiliser pour les boîtes, couvercles et autres pièces à parois minces.",
|
||||
"help.solid.sweep": "Entraîne le profil d'une esquisse le long d'une trajectoire pour obtenir un solide. À utiliser pour les tuyaux, rails, poignées et toute pièce de section constante qui suit une courbe.",
|
||||
"help.third_party": "Licences tierces",
|
||||
"home.back": "Revenir à {name}",
|
||||
"home.crashes": "Rapports de plantage",
|
||||
@@ -1423,7 +1430,6 @@
|
||||
"ui.ai_permissions": "Autorisations",
|
||||
"ui.ai_preset": "Préréglage",
|
||||
"ui.ai_remove": "Retirer",
|
||||
"ui.ai_request_limit": "arrêté après 24 requêtes pour un message ; envoyez un autre message pour poursuivre",
|
||||
"ui.ai_reset": "Réinitialiser",
|
||||
"ui.ai_settings": "Réglages IA…",
|
||||
"ui.ai_show_features": "Afficher les fonctionnalités IA",
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
"Show Agent Cursor": "Mostrar o cursor do axente",
|
||||
"Show in folder": "Mostrar no cartafol",
|
||||
"Show the pointer an AI agent moves (#35)": "Mostrar o punteiro que move un axente de IA (#35)",
|
||||
"Show tool at cursor": "Mostrar a ferramenta xunto ao cursor",
|
||||
"Signed axes": "Eixes con signo",
|
||||
"Size": "Tamaño",
|
||||
"Sketch constraints": "Restricións do bosquexo",
|
||||
@@ -1415,7 +1416,6 @@
|
||||
"ui.ai_permissions": "Permisos",
|
||||
"ui.ai_preset": "Axuste predefinido",
|
||||
"ui.ai_remove": "Quitar",
|
||||
"ui.ai_request_limit": "detívose tras 24 solicitudes para unha mensaxe; envía outra mensaxe para continuar",
|
||||
"ui.ai_reset": "Restablecer",
|
||||
"ui.ai_settings": "Axustes de IA…",
|
||||
"ui.ai_show_features": "Mostrar as funcións de IA",
|
||||
|
||||
@@ -334,6 +334,7 @@
|
||||
"Show Agent Cursor": "Ügynök kurzorának megjelenítése",
|
||||
"Show in folder": "Megjelenítés a mappában",
|
||||
"Show the pointer an AI agent moves (#35)": "Az AI-ügynök által mozgatott mutató megjelenítése (#35)",
|
||||
"Show tool at cursor": "Eszköz megjelenítése a kurzornál",
|
||||
"Signed axes": "Előjeles tengelyek",
|
||||
"Size": "Méret",
|
||||
"Sketch constraints": "Vázlatkényszerek",
|
||||
@@ -1358,7 +1359,6 @@
|
||||
"ui.ai_permissions": "Jogosultságok",
|
||||
"ui.ai_preset": "Előbeállítás",
|
||||
"ui.ai_remove": "Eltávolítás",
|
||||
"ui.ai_request_limit": "egy üzenetre eső 24 kérés után leállt; küldjön újabb üzenetet a folytatáshoz",
|
||||
"ui.ai_reset": "Visszaállítás",
|
||||
"ui.ai_settings": "AI-beállítások…",
|
||||
"ui.ai_show_features": "AI-funkciók megjelenítése",
|
||||
|
||||
@@ -334,6 +334,7 @@
|
||||
"Show Agent Cursor": "Tampilkan Kursor Agen",
|
||||
"Show in folder": "Tampilkan di folder",
|
||||
"Show the pointer an AI agent moves (#35)": "Tampilkan penunjuk yang digerakkan agen AI (#35)",
|
||||
"Show tool at cursor": "Tampilkan alat di dekat kursor",
|
||||
"Signed axes": "Sumbu bertanda",
|
||||
"Size": "Ukuran",
|
||||
"Sketch constraints": "Batasan sketsa",
|
||||
@@ -1358,7 +1359,6 @@
|
||||
"ui.ai_permissions": "Izin",
|
||||
"ui.ai_preset": "Preset",
|
||||
"ui.ai_remove": "Hapus",
|
||||
"ui.ai_request_limit": "dihentikan setelah 24 permintaan untuk satu pesan; kirim pesan lain untuk melanjutkan",
|
||||
"ui.ai_reset": "Reset",
|
||||
"ui.ai_settings": "Pengaturan AI…",
|
||||
"ui.ai_show_features": "Tampilkan fitur AI",
|
||||
|
||||
+15
-1
@@ -164,6 +164,7 @@
|
||||
"From Bodies": "Dai corpi",
|
||||
"From edges": "Spigoli di partenza",
|
||||
"Full Length": "Lunghezza totale",
|
||||
"Full thread depth": "Profondità filetto completa",
|
||||
"Gap": "Gioco",
|
||||
"Grid": "Griglia",
|
||||
"HISTORY": "CRONOLOGIA",
|
||||
@@ -338,6 +339,7 @@
|
||||
"Show Agent Cursor": "Mostra cursore dell'agente",
|
||||
"Show in folder": "Mostra nella cartella",
|
||||
"Show the pointer an AI agent moves (#35)": "Mostra il puntatore mosso da un agente IA (#35)",
|
||||
"Show tool at cursor": "Mostra lo strumento accanto al cursore",
|
||||
"Signed axes": "Assi con segno",
|
||||
"Size": "Dimensioni",
|
||||
"Sketch constraints": "Vincoli dello schizzo",
|
||||
@@ -381,6 +383,7 @@
|
||||
"This child is shared. Deleting it removes it from every copy of its parent.": "Questo elemento figlio è condiviso. Eliminandolo verrà rimosso da ogni copia del padre.",
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "Questo elemento figlio è condiviso. Le modifiche alla posizione si applicano a ogni copia del padre.",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Questa faccia non consente un collegamento persistente. Scegli un'altra faccia o usa esplicitamente un piano fisso.",
|
||||
"Thread depth": "Profondità filetto",
|
||||
"To Offset": "Fino a una distanza",
|
||||
"To edges": "Spigoli di arrivo",
|
||||
"Tolerance": "Tolleranza",
|
||||
@@ -998,6 +1001,16 @@
|
||||
"pattern.direction_1": "Direzione 1",
|
||||
"pattern.direction_2": "Direzione 2",
|
||||
"pattern.flip_direction": "Inverti direzione",
|
||||
"perf.click_for_details": "Fai clic per i dettagli: grafico dei tempi, istogramma e ciò che la GPU ha disegnato",
|
||||
"perf.draw_calls": "Chiamate di disegno",
|
||||
"perf.line_segments": "Segmenti",
|
||||
"perf.low_1": "1% più lento",
|
||||
"perf.renderer": "Renderer",
|
||||
"perf.scene_build": "Costruzione scena",
|
||||
"perf.triangles": "Triangoli",
|
||||
"perf.ui_time": "Tempo UI",
|
||||
"perf.viewport": "Vista",
|
||||
"perf.worst": "peggiore",
|
||||
"pick a hole to seat it, or OK places it at the origin": "seleziona un foro in cui inserirlo, oppure premi OK per posizionarlo nell'origine",
|
||||
"pick one or two vertices, edges or faces": "seleziona uno o due vertici, spigoli o facce",
|
||||
"prefs.cpu": "CPU (software)",
|
||||
@@ -1073,6 +1086,7 @@
|
||||
"search.try": "Digita il nome di un comando, una feature di questo progetto o un parametro. Per eseguire un comando con dei valori, digita il suo id e un oggetto JSON: {example}",
|
||||
"search.valid": "Valido",
|
||||
"settings.display_quality_note": "I livelli più fini rendono più uniformi le facce curve e gli spigoli e richiedono più tempo per la mesh. Cambia solo la vista, mai il progetto o le esportazioni.",
|
||||
"settings.frame_rate_details_note": "Aggiunge un grafico dei tempi degli ultimi fotogrammi con i triangoli disegnati sopra, un istogramma dei tempi dei fotogrammi e ciò che la GPU ha disegnato. Fai clic sulla frequenza nella vista per mostrarli o nasconderli.",
|
||||
"settings.frame_rate_note": "Mostra i fotogrammi al secondo nell'angolo inferiore destro della vista.",
|
||||
"settings.measure_axis_distances": "Mostra distanze X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Mostra le componenti lungo gli assi globali accanto alla distanza totale quando Misura è aperto.",
|
||||
@@ -1418,7 +1432,6 @@
|
||||
"ui.ai_permissions": "Autorizzazioni",
|
||||
"ui.ai_preset": "Preimpostazione",
|
||||
"ui.ai_remove": "Rimuovi",
|
||||
"ui.ai_request_limit": "arrestato dopo 24 richieste per un messaggio; invia un altro messaggio per proseguire",
|
||||
"ui.ai_reset": "Reimposta",
|
||||
"ui.ai_settings": "Impostazioni IA…",
|
||||
"ui.ai_show_features": "Mostra funzioni IA",
|
||||
@@ -1602,6 +1615,7 @@
|
||||
"ui.flip": "Inverti",
|
||||
"ui.format": "Formato",
|
||||
"ui.frame_rate": "Frequenza dei fotogrammi",
|
||||
"ui.frame_rate_details": "Dettagli della frequenza dei fotogrammi",
|
||||
"ui.front": "Frontale",
|
||||
"ui.fully_constrained": "Completamente vincolato",
|
||||
"ui.function": "Funzione",
|
||||
|
||||
+64
-1
@@ -8,6 +8,7 @@
|
||||
"A Single hole snaps to the middle of its face and to the centres of its round edges": "単一の穴を面の中心と丸いエッジの中心にスナップします",
|
||||
"A new configuration, a copy of the active one": "アクティブな構成をコピーして新しい構成を作成",
|
||||
"A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out": "配置した単一の穴にドラッグ用のハンドルを付け、面をもう一度選ぶと穴を削除せずにそこへ移動します",
|
||||
"A short title for the problem (optional)": "問題の短いタイトル(省略可)",
|
||||
"ARRANGE": "配置",
|
||||
"About SolidCraft": "SolidCraft について",
|
||||
"Acceleration": "加速度",
|
||||
@@ -21,6 +22,7 @@
|
||||
"Add Mid Radius": "中間半径を追加",
|
||||
"Add Row": "行を追加",
|
||||
"Add a tool": "ツールを追加",
|
||||
"Add bend": "曲げを追加",
|
||||
"Add condition": "条件を追加",
|
||||
"Add or edit rule": "ルールを追加または編集",
|
||||
"Add to New Folder": "新しいフォルダーに追加",
|
||||
@@ -35,7 +37,9 @@
|
||||
"Also deletes": "同時に削除される要素:",
|
||||
"Anchor": "基準点",
|
||||
"Angle 2": "角度 2",
|
||||
"Angle Display": "角度の表示",
|
||||
"Animate": "アニメーション",
|
||||
"Another bend on the far edge of the last flange": "最後のフランジの先端にさらに曲げを追加",
|
||||
"App/system information and command settings are included. Review private details before sharing.": "アプリ・システム情報とコマンド設定が含まれます。共有前に非公開の情報を確認してください。",
|
||||
"Appearance name": "外観名",
|
||||
"Applied Radius": "適用半径",
|
||||
@@ -52,6 +56,7 @@
|
||||
"BOUNDARY SURFACE": "境界サーフェス",
|
||||
"BROWSER": "ブラウザ",
|
||||
"Bar": "平鋼",
|
||||
"Bend": "曲げ角度",
|
||||
"Bend Edges": "曲げエッジ",
|
||||
"Bend Line Position": "曲げ線位置",
|
||||
"Bend Position": "曲げ位置",
|
||||
@@ -218,6 +223,7 @@
|
||||
"Fit and G2 corners require Constant radius.": "フィットと G2 コーナーには Constant radius が必要です。",
|
||||
"Fixed Face": "固定面",
|
||||
"Flange thickness": "フランジ厚",
|
||||
"Flanges": "フランジ",
|
||||
"Flat Pattern": "フラットパターン",
|
||||
"Flat size": "展開サイズ",
|
||||
"Follow camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
|
||||
@@ -227,6 +233,8 @@
|
||||
"From Bodies": "ボディから",
|
||||
"From edges": "開始側エッジ",
|
||||
"Full Length": "全長",
|
||||
"Full thread depth": "ねじ深さ全体",
|
||||
"Further Bends": "追加の曲げ",
|
||||
"G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet.": "G2 コーナーには一定半径と Exact fit が必要です。G2 コーナーの構築はまだサポートされていません。",
|
||||
"Gap": "間隔",
|
||||
"Grid": "グリッド",
|
||||
@@ -247,6 +255,7 @@
|
||||
"In This Design": "このデザイン内",
|
||||
"In place (surfaces)": "その場で(サーフェス)",
|
||||
"In plane": "平面内",
|
||||
"Included": "挟角",
|
||||
"Inside": "内側",
|
||||
"Instant": "即時",
|
||||
"Interface font": "インターフェースフォント",
|
||||
@@ -305,6 +314,7 @@
|
||||
"Mitre": "留め継ぎ",
|
||||
"Model Parameters": "モデルパラメータ",
|
||||
"Modeled": "モデル化済み",
|
||||
"More themes": "その他のテーマ",
|
||||
"More toolbar panels": "その他のツールバーパネル",
|
||||
"Motion": "モーション",
|
||||
"Mouse gestures": "マウス ジェスチャ",
|
||||
@@ -332,6 +342,7 @@
|
||||
"Nominal diameter": "呼び径",
|
||||
"None": "なし",
|
||||
"Not available yet": "まだ利用できません",
|
||||
"Not saved yet": "まだ保存されていません",
|
||||
"Notch": "ノッチ",
|
||||
"OUTPUT": "出力",
|
||||
"Of": "の値",
|
||||
@@ -347,6 +358,7 @@
|
||||
"Orbit (right drag)": "回転(右ドラッグ)",
|
||||
"Other side": "反対側",
|
||||
"Other surface (mutual trim)": "相手のサーフェス(相互トリム)",
|
||||
"Other way": "反対方向",
|
||||
"Outer radius": "外側コーナー半径",
|
||||
"Outside": "外側",
|
||||
"Overall length": "全長",
|
||||
@@ -376,6 +388,7 @@
|
||||
"Preview": "プレビュー",
|
||||
"Preview Row": "行をプレビュー",
|
||||
"Preview leaves the current design unchanged.": "プレビューは現在のデザインを変更しません。",
|
||||
"Previous themes": "前のテーマ",
|
||||
"Profile / Edges": "プロファイル / エッジ",
|
||||
"Profiles or curves": "プロファイルまたは曲線",
|
||||
"Profiles/Curves": "プロファイル/曲線",
|
||||
@@ -402,6 +415,7 @@
|
||||
"Remove screenshot": "スクリーンショットを削除",
|
||||
"Remove the column": "列を削除",
|
||||
"Remove the track": "トラックを削除",
|
||||
"Remove this bend": "この曲げを削除",
|
||||
"Remove this point": "この点を削除",
|
||||
"Rename Feature": "フィーチャーの名前を変更",
|
||||
"Rename Folder": "フォルダーの名前を変更",
|
||||
@@ -443,6 +457,7 @@
|
||||
"Rules run from top to bottom. Matched drives change effective values.": "ルールは上から順に実行されます。条件に一致する駆動ルールが有効値を変更します。",
|
||||
"SECTION": "断面",
|
||||
"SURFACE QUALITY": "サーフェス品質",
|
||||
"Same way": "同じ方向",
|
||||
"Save Rule": "ルールを保存",
|
||||
"Save View": "ビューを保存",
|
||||
"Save screenshot…": "スクリーンショットを保存…",
|
||||
@@ -471,8 +486,10 @@
|
||||
"Shape": "形状",
|
||||
"Sheet Rules": "板金ルール",
|
||||
"Show Agent Cursor": "エージェントカーソルを表示",
|
||||
"Show ghost": "ゴーストを表示",
|
||||
"Show in folder": "フォルダに表示",
|
||||
"Show the pointer an AI agent moves (#35)": "エージェントが操作するポインターを表示 (#35)",
|
||||
"Show tool at cursor": "カーソルの横にツールを表示",
|
||||
"Signed axes": "符号付き座標軸",
|
||||
"Size": "サイズ",
|
||||
"Sketch constraints": "スケッチ拘束",
|
||||
@@ -529,6 +546,7 @@
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "この子要素は共有されています。位置の変更は親のすべてのコピーに適用されます。",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "この面は安定したアタッチメントを提供できません。別の面を選択するか、固定平面を明示的に使用してください。",
|
||||
"This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "この画像はまだGitHubに添付されていません。コピーまたは保存し、GitHubで添付してください。",
|
||||
"Thread depth": "ねじ深さ",
|
||||
"Thread diameter": "ねじ径",
|
||||
"To": "終了",
|
||||
"To Offset": "オフセットまで",
|
||||
@@ -588,10 +606,13 @@
|
||||
"Z angle": "Z角度",
|
||||
"Z distance": "Z距離",
|
||||
"Zebra": "ゼブラ",
|
||||
"ai.connected_toast": "SolidCraft が {clients} で使えるようになりました。使うには再起動してください(設定 > アシスタント)。",
|
||||
"ai.prefs.allow_control": "AI エージェントの接続を許可(コントロールチャンネル / MCP)",
|
||||
"ai.prefs.allow_control_note": "このコンピューター内だけのコントロールチャンネル(127.0.0.1、ポート 27878 または次に空いているポート)を開きます。接続するプログラムは、起動のたびに作られ、あなたのユーザーアカウントだけが読める秘密のトークンも提示する必要があります。次に SolidCraft を起動したときから有効です。オフ:--control を付けて起動しない限り、何も待ち受けません。",
|
||||
"ai.prefs.availability": "アシスタントの操作",
|
||||
"ai.prefs.background_commands": "エージェントのコマンドをバックグラウンドで実行",
|
||||
"ai.prefs.connect_apps": "このコンピューターの AI アプリで SolidCraft を使えるようにする",
|
||||
"ai.prefs.connect_apps_note": "SolidCraft の起動時に、このコンピューターで見つかった AI アプリ(OpenCode、Claude Desktop、Claude Code、Cursor、Codex、Gemini CLI、Copilot CLI、Windsurf、Zed、VS Code)へ MCP サーバーとして追加します。追加または更新するのは solidcraft のエントリのみで、変更済みのエントリには触れず、ファイルは先にバックアップされます。オフにすると追加したエントリを削除します。",
|
||||
"ai.prefs.follow_camera": "カメラをスケッチに向ける",
|
||||
"ai.prefs.follow_camera_off": "カメラの自動追従はオフです。",
|
||||
"ai.prefs.follow_camera_on": "エージェントが作成するスケッチにカメラを向けます。",
|
||||
@@ -756,6 +777,7 @@
|
||||
"cmd.inspect_interference": "干渉",
|
||||
"cmd.inspect_isocurve": "アイソカーブ解析",
|
||||
"cmd.inspect_minimum_radius": "最小半径解析",
|
||||
"cmd.inspect_parameter_sweep": "パラメータスイープ",
|
||||
"cmd.inspect_silhouette": "シルエット",
|
||||
"cmd.inspect_zebra": "ゼブラ解析",
|
||||
"cmd.joint_as_built": "現位置ジョイント",
|
||||
@@ -881,6 +903,7 @@
|
||||
"cmd.sheet_fold": "折り曲げ",
|
||||
"cmd.sheet_hem": "ヘミング",
|
||||
"cmd.sheet_manage_rules": "板金ルール",
|
||||
"cmd.sheet_path_flange": "パスフランジ",
|
||||
"cmd.sheet_perforate": "パンチング",
|
||||
"cmd.sheet_refold": "折り戻し",
|
||||
"cmd.sheet_rules": "板金ルールを一覧表示",
|
||||
@@ -1073,7 +1096,18 @@
|
||||
"dialog.selection_guidance": "入力欄を選んで変更します。選択済みのオブジェクトをクリックすると解除できます。Ctrl/Cmd を押しながらクリックしても切り替えられます。プレビューを確認してから OK を押してください。",
|
||||
"dialog.selection_step": "{total} ステップ中 {step}: {input} を選択",
|
||||
"dialog.step": "ステップ",
|
||||
"document.changed_on_disk": "{name} がディスク上で変更されました",
|
||||
"document.changed_on_disk_detail": "{name} は、開いたとき(または最後に保存したとき)以降に別のプログラムまたはコンピューターによって変更されました。上書きするとその変更は失われます。コピーを保存すると両方が残ります。",
|
||||
"document.copy_name": "{name} のコピー",
|
||||
"document.open_anyway": "このまま開く",
|
||||
"document.open_elsewhere": "{name} はすでに開かれています",
|
||||
"document.open_elsewhere_detail": "{name} は {who} の SolidCraft {app} で開かれています({when}に開かれました)。両方で保存すると、どちらかの作業が失われます。上書きせずに確認するには読み取り専用で開いてください。",
|
||||
"document.open_read_only": "読み取り専用で開く",
|
||||
"document.opened_read_only": "読み取り専用で開きました:保存すると新しいファイル名を尋ねます",
|
||||
"document.overwrite": "上書き",
|
||||
"document.read_only_save": "このデザインは読み取り専用で開かれています:新しいファイルとして保存してください",
|
||||
"document.save_changes": "{name} への変更を保存しますか?",
|
||||
"document.save_copy": "コピーを保存…",
|
||||
"document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。",
|
||||
"drive.stopped_by_contact": "{value} で停止: {a} が {b} を通り抜けてしまいます",
|
||||
"drive.stopped_by_loop": "{value} で停止: {joint} を通るループはこれ以上閉じられません",
|
||||
@@ -1109,17 +1143,25 @@
|
||||
"help.apache": "Apacheライセンス2.0",
|
||||
"help.attributions": "帰属表示 (ATTRIBUTION.md)",
|
||||
"help.build": "バージョン {version} · コミット {commit} · ビルド日 {date}",
|
||||
"help.connect_note": "各アシスタントを再起動すると SolidCraft サーバーが読み込まれます。変更したファイルは .bak としてバックアップされています。",
|
||||
"help.connect_title": "AI アシスタントを接続",
|
||||
"help.font_details": "インターフェースにはegui組み込みのフォントを使用しています(Ubuntu Font LicenceおよびMIT/Bitstream VeraライセンスのUbuntu LightとHack、OFLおよびMITのNoto Emojiと絵文字アイコンフォント)。スケッチのテキストにはLiberation Sans(SIL OFL 1.1)を使用しています。The Crafting Appsのオプションフォントパックcraft-fonts(BIZ UDPGothic、Shippori Mincho、BIZ UDMincho、Noto Sans CJK SC、Noto Sans Arabic、SIL OFL 1.1)はSolidCraftのビルドに同梱されていません。",
|
||||
"help.fonts": "フォント",
|
||||
"help.licences": "ライセンス",
|
||||
"help.mit": "MITライセンス",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "選択した辺を、指定した距離で平らに斜めに削ります。鋭い角を落とすときや、ピンやねじを入れやすくするときに使います。",
|
||||
"help.solid.combine": "複数の立体を結合したり、対象の立体から切り取ったり、共通する体積だけを残したりでき、工具の立体を残すこともできます。部品の結合、ある形での切り抜き、2つの立体が重なる範囲の確認に使います。",
|
||||
"help.solid.extrude": "スケッチの輪郭を指定した距離だけ立体方向に押し出し、材料を足したり削ったりします。平面の図形をブロック、突起、ポケットにするときに使います。",
|
||||
"help.solid.fillet": "選択した辺を、指定した半径のなめらかな円弧で丸めます。強度や持ちやすさ、見た目のために鋭い角を和らげるときに使います。",
|
||||
"help.solid.hole": "面上の指定した位置に、指定した直径の丸い穴をあけます。ねじのクリアランスやピン穴など、単純な丸い開口に使います。",
|
||||
"help.solid.loft": "2つ以上のスケッチの輪郭の間、または輪郭から点までを、なめらかな立体でつなぎます。移行部、漏斗、ハンドルなど、長さに沿って断面が変わる形に使います。",
|
||||
"help.solid.mirror": "選択した立体またはフィーチャを平面に対して鏡像でコピーし、元の立体と結合することもできます。左右対称の部品で、半分だけモデリングして両側を得るときに使います。",
|
||||
"help.solid.pattern.circular": "選択したオブジェクトを、指定した数と全体の角度で軸のまわりに繰り返し配置します。ボルト円、スポーク、フィン、歯などに使います。",
|
||||
"help.solid.pattern.rectangular": "選択したオブジェクトを、指定した数と間隔で1方向または2方向に繰り返し配置します。穴、リブ、ピン、脚の列や格子状の配置に使います。",
|
||||
"help.solid.revolve": "スケッチの輪郭を軸のまわりに回転させ、丸い立体を作ります。軸、つまみ、ボウルなど、中心線まわりに丸い部品に使います。",
|
||||
"help.solid.shell": "立体の中をくり抜き、指定した厚さの壁を残します。選んだ面を開口にすることもできます。箱、カバーなどの薄肉の部品に使います。",
|
||||
"help.solid.sweep": "スケッチの輪郭をパスに沿って動かし、立体を作ります。パイプ、レール、ハンドルなど、一定の断面で曲線をたどる部品に使います。",
|
||||
"help.third_party": "サードパーティライセンス",
|
||||
"home.back": "{name} に戻る",
|
||||
"home.crashes": "クラッシュレポート",
|
||||
@@ -1301,8 +1343,19 @@
|
||||
"pattern.direction_1": "方向 1",
|
||||
"pattern.direction_2": "方向 2",
|
||||
"pattern.flip_direction": "方向を反転",
|
||||
"perf.click_for_details": "クリックで詳細: フレーム時間のグラフ、ヒストグラム、GPU の描画内容",
|
||||
"perf.draw_calls": "描画コール",
|
||||
"perf.line_segments": "線分",
|
||||
"perf.low_1": "下位 1%",
|
||||
"perf.renderer": "レンダラー",
|
||||
"perf.scene_build": "シーン構築",
|
||||
"perf.triangles": "三角形",
|
||||
"perf.ui_time": "UI 時間",
|
||||
"perf.viewport": "ビュー",
|
||||
"perf.worst": "最悪",
|
||||
"pick a hole to seat it, or OK places it at the origin": "配置先の穴を選択するか、[OK]を押すと原点に配置します。",
|
||||
"pick one or two vertices, edges or faces": "1つまたは2つの頂点、エッジ、面を選択",
|
||||
"pick the path": "パスを選択",
|
||||
"prefs.add_item": "項目を追加",
|
||||
"prefs.alt_frees_manipulators": "Alt / Option でマニピュレーターの矢印も自由に動かす",
|
||||
"prefs.anti_aliasing_note": "輪郭のぎざぎざを滑らかにします。",
|
||||
@@ -2052,7 +2105,9 @@
|
||||
"search.try": "コマンド名、このデザインのフィーチャー、またはパラメーターを入力します。値を指定して実行するには、IDとJSONオブジェクトを入力します:{example}",
|
||||
"search.valid": "有効",
|
||||
"section.off_hint": "平面が選択されていません。OK を押すとセクションを解除します。",
|
||||
"set by the sketch's lines": "スケッチの線で決まります",
|
||||
"settings.display_quality_note": "表示だけに影響します。設計や書き出しは変わりません。",
|
||||
"settings.frame_rate_details_note": "直近のフレーム時間のグラフ(描画した三角形数を重ねて表示)、フレーム時間のヒストグラム、GPU が描画した内容を追加します。ビューのフレームレートをクリックすると表示/非表示を切り替えます。",
|
||||
"settings.frame_rate_note": "ビューの右下に 1 秒あたりのフレーム数を表示します。",
|
||||
"settings.measure_axis_distances": "計測のX・Y・Z距離を表示",
|
||||
"settings.measure_axis_distances_help": "計測ダイアログを開いている間、2点間距離に加えてワールドX・Y・Z軸方向の寸法線と距離を表示します。",
|
||||
@@ -2107,6 +2162,7 @@
|
||||
"timeline.delete_after_marker_one": "履歴マーカー以降の 1 個のフィーチャーを削除",
|
||||
"timeline.delete_after_marker_other": "履歴マーカー以降の {count} 個のフィーチャーを削除",
|
||||
"timeline.delete_many": "選択した {count} 個のフィーチャーを削除",
|
||||
"timeline.feature_hint": "ダブルクリックで編集 · ドラッグで並べ替え · 右クリックでその他の操作",
|
||||
"timeline.group": "フィーチャーフォルダを新規作成",
|
||||
"timeline.move_many": "タイムライン項目を移動",
|
||||
"timeline.move_to_group": "フィーチャーをフォルダへ移動",
|
||||
@@ -2254,6 +2310,7 @@
|
||||
"ui.agent_chat_auth_title": "エージェントにサインイン",
|
||||
"ui.agent_chat_cancel": "キャンセル",
|
||||
"ui.agent_chat_changes": "{n} 件の変更(元に戻すは1回)",
|
||||
"ui.agent_chat_changes_n": "{n} 件の変更",
|
||||
"ui.agent_chat_changes_of": "変更 {n}/{limit}",
|
||||
"ui.agent_chat_close": "閉じる",
|
||||
"ui.agent_chat_command": "エージェントのコマンド",
|
||||
@@ -2358,6 +2415,7 @@
|
||||
"ui.agent_chat_stop_tip": "エージェントと、それが起動したものをすべて停止します",
|
||||
"ui.agent_chat_stop_turn_tip": "エージェントを停止します(Esc)。待機中のメッセージは待機のままです。",
|
||||
"ui.agent_chat_turn_limit": "1メッセージあたりの変更数",
|
||||
"ui.agent_chat_turn_unlimited": "無制限",
|
||||
"ui.agent_chat_undo_changes": "これらの変更を元に戻す",
|
||||
"ui.agent_chat_undo_label": "エージェントチャット: {message}",
|
||||
"ui.agent_chat_undone": "(元に戻しました)",
|
||||
@@ -2426,7 +2484,9 @@
|
||||
"ui.ai_preset": "プリセット",
|
||||
"ui.ai_protocol": "プロトコル",
|
||||
"ui.ai_remove": "削除",
|
||||
"ui.ai_request_limit": "1メッセージあたり24リクエストで停止しました。続けるには、もう一度メッセージを送ってください",
|
||||
"ui.ai_request_timed_out": "モデルの応答に {seconds} 秒以上かかりました。このエンドポイントのリクエストのタイムアウトは、環境設定 › アシスタントで長くできます。",
|
||||
"ui.ai_request_timeout": "リクエストのタイムアウト",
|
||||
"ui.ai_request_timeout_hint": "このエンドポイントへのモデルリクエスト1回にかけられる時間です(30秒〜1時間)。遅いローカルモデルでは長くしてください。",
|
||||
"ui.ai_reset": "リセット",
|
||||
"ui.ai_settings": "AI 設定…",
|
||||
"ui.ai_show_features": "AI 機能を表示",
|
||||
@@ -2617,6 +2677,7 @@
|
||||
"ui.flip": "反転",
|
||||
"ui.format": "形式",
|
||||
"ui.frame_rate": "フレームレート",
|
||||
"ui.frame_rate_details": "フレームレートの詳細",
|
||||
"ui.front": "正面",
|
||||
"ui.fully_constrained": "完全拘束",
|
||||
"ui.function": "関数",
|
||||
@@ -2957,6 +3018,8 @@
|
||||
"ui.start": "開始",
|
||||
"ui.start_over": "最初からやり直す",
|
||||
"ui.start_page": "スタートページ",
|
||||
"ui.step_values": "矢印キーとホイールで数値を増減",
|
||||
"ui.step_values_tip": "入力中の値フィールドでは、上下矢印キーとフィールド上のマウスホイールで数値を 1 ずつ増減します(Shift: 10、Alt: 0.1)。式はそのまま残ります。",
|
||||
"ui.stretch": "伸縮",
|
||||
"ui.supersampling": "スーパーサンプリング (SSAA)",
|
||||
"ui.supersampling_adaptive": "ビューが静止しているときのみ",
|
||||
|
||||
+299
-1
@@ -14,11 +14,15 @@
|
||||
"Active": "활성",
|
||||
"Active rule": "활성 규칙",
|
||||
"Add": "추가",
|
||||
"Add Column": "열 추가",
|
||||
"Add Key": "키 추가",
|
||||
"Add Mid Radius": "중간 반지름 추가",
|
||||
"Add Row": "행 추가",
|
||||
"Add a tool": "도구 추가",
|
||||
"Add condition": "조건 추가",
|
||||
"Add or edit rule": "규칙 추가 또는 편집",
|
||||
"Add to New Folder": "새 폴더에 추가",
|
||||
"Advanced": "고급",
|
||||
"Against": "상대",
|
||||
"Alignment": "정렬",
|
||||
"All Sketch Points": "모든 스케치 점",
|
||||
@@ -28,9 +32,11 @@
|
||||
"Also deletes": "함께 삭제됨",
|
||||
"Angle 2": "각도 2",
|
||||
"Animate": "애니메이션",
|
||||
"App/system information and command settings are included. Review private details before sharing.": "앱/시스템 정보와 명령 설정이 포함됩니다. 공유하기 전에 개인 정보를 확인하세요.",
|
||||
"Appearance name": "외관 이름",
|
||||
"Applied Radius": "적용 반지름",
|
||||
"Apply To": "적용 대상",
|
||||
"Apply library appearance": "라이브러리 외관 적용",
|
||||
"Apply to selection": "선택 항목에 적용",
|
||||
"Apply your appearance first. Exports assigned appearances and materials created in this design.": "먼저 외관을 적용하세요. 지정된 외관과 이 디자인에서 만든 재질을 내보냅니다.",
|
||||
"Arc length": "호 길이",
|
||||
@@ -41,13 +47,18 @@
|
||||
"BLEND SURFACE": "블렌드 곡면",
|
||||
"BOUNDARY SURFACE": "경계 곡면",
|
||||
"BROWSER": "브라우저",
|
||||
"Bend Edges": "벤딩 모서리",
|
||||
"Bend Line Position": "벤딩 선 위치",
|
||||
"Bend Position": "벤딩 위치",
|
||||
"Bend Radius": "벤딩 반지름",
|
||||
"Bend Table": "벤딩 표",
|
||||
"Bends": "벤딩",
|
||||
"Bodies": "바디",
|
||||
"Bodies (optional)": "바디 (선택 사항)",
|
||||
"Body": "바디",
|
||||
"Body (last if none)": "바디 (없으면 마지막)",
|
||||
"Body to split": "분할할 바디",
|
||||
"Body, or faces to take out": "바디 또는 제거할 면",
|
||||
"Boundary": "경계",
|
||||
"Bounding box": "경계 상자",
|
||||
"CHECK": "검사",
|
||||
@@ -59,7 +70,9 @@
|
||||
"CSV or JSON: updates existing parameters, adds new ones": "CSV or JSON: updates existing parameters, adds new ones",
|
||||
"Capture All Positions": "모든 위치 캡처",
|
||||
"Capture Pos.": "위치 캡처",
|
||||
"Catch Depth": "걸림 깊이",
|
||||
"Catch Direction": "받기 방향",
|
||||
"Catch Length": "걸림 길이",
|
||||
"Cells": "셀",
|
||||
"Center X": "중심 X",
|
||||
"Center Y": "중심 Y",
|
||||
@@ -68,23 +81,31 @@
|
||||
"Chamfer Type": "모따기 유형",
|
||||
"Check collisions": "충돌 확인",
|
||||
"Checked": "검사됨",
|
||||
"Choose Variable radius or Spline in Type.": "유형에서 가변 반지름 또는 스플라인을 선택하세요.",
|
||||
"Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "새 평면을 선택하세요. 스케치 기하와 치수는 2차원 좌표 그대로 유지됩니다.",
|
||||
"Choose…": "선택…",
|
||||
"Circular": "원형",
|
||||
"Circumference": "원주",
|
||||
"Clockwise": "시계 방향",
|
||||
"Close draft": "초안 닫기",
|
||||
"Collapse": "접기",
|
||||
"Collisions": "충돌",
|
||||
"Colour": "색상",
|
||||
"Command": "명령",
|
||||
"Comment": "설명",
|
||||
"Component 1": "구성요소 1",
|
||||
"Component 2": "구성요소 2",
|
||||
"Component1": "구성요소1",
|
||||
"Components": "구성요소",
|
||||
"Config Table": "구성 표",
|
||||
"Configuration": "구성",
|
||||
"Configuration rules": "Configuration rules",
|
||||
"Contact": "접촉",
|
||||
"Contact Sets": "접촉 집합",
|
||||
"Contact sets": "접촉 집합",
|
||||
"Continue on GitHub": "GitHub에서 계속",
|
||||
"Continuity": "연속성",
|
||||
"Control Radius": "제어 반지름",
|
||||
"Copy Error Message": "오류 메시지 복사",
|
||||
"Copy Warning Message": "경고 메시지 복사",
|
||||
"Copy all licences": "모든 라이선스 복사",
|
||||
@@ -94,8 +115,11 @@
|
||||
"Corner Relief": "모서리 릴리프",
|
||||
"Corner Relief Shape": "모서리 릴리프 모양",
|
||||
"Counterbore depth": "카운터보어 깊이",
|
||||
"Counterbore Ø": "카운터보어 Ø",
|
||||
"Countersink angle": "카운터싱크 각도",
|
||||
"Countersink Ø": "카운터싱크 Ø",
|
||||
"Create Issue — review draft": "이슈 만들기 — 초안 검토",
|
||||
"Create Issue…": "이슈 만들기…",
|
||||
"Cross curves": "교차 곡선",
|
||||
"Cube": "큐브",
|
||||
"Current orthographic view (1:1 mm)": "현재 직교 투영 뷰 (1:1 mm)",
|
||||
@@ -103,18 +127,28 @@
|
||||
"Curv. Comb": "곡률 빗",
|
||||
"Curvature (G2)": "곡률 (G2)",
|
||||
"Curvature change (G3)": "곡률 변화 (G3)",
|
||||
"Custom": "사용자 지정",
|
||||
"Customize Toolbar": "도구 모음 사용자 지정",
|
||||
"Customize…": "사용자 지정…",
|
||||
"Cylindrical face": "원통면",
|
||||
"Delete": "삭제",
|
||||
"Delete Selected Features…": "선택한 피처 삭제…",
|
||||
"Delete Selected Folders": "선택한 폴더 삭제",
|
||||
"Delete all": "모두 삭제",
|
||||
"Delete rule": "규칙 삭제",
|
||||
"Delete the row": "행 삭제",
|
||||
"Delete the set": "집합 삭제",
|
||||
"Density": "밀도",
|
||||
"Dependents": "종속 항목",
|
||||
"Describe the problem you saw": "발견한 문제를 설명하세요",
|
||||
"Describe what happened before continuing.": "계속하기 전에 어떤 일이 있었는지 설명하세요.",
|
||||
"Designs you open or save appear here.": "열거나 저장한 디자인이 여기에 표시됩니다.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "대상 객체까지로 결정됩니다. 각도 범위를 편집하려면 각도를 선택하세요.",
|
||||
"Diameter": "지름",
|
||||
"Direction": "방향",
|
||||
"Discard": "버리기",
|
||||
"Distance": "거리",
|
||||
"Distance 1": "거리 1",
|
||||
"Do not export hidden edges": "숨겨진 모서리 내보내지 않기",
|
||||
"Don't ask again": "다시 묻지 않기",
|
||||
"Double-click to rename": "Double-click to rename",
|
||||
@@ -126,17 +160,22 @@
|
||||
"Edit Profile Sketch": "윤곽 스케치 편집",
|
||||
"Enable rule": "규칙 사용",
|
||||
"Enabled": "사용",
|
||||
"End Radius": "끝 반지름",
|
||||
"End bulge": "끝 벌지",
|
||||
"End continuity": "끝 연속성",
|
||||
"Expand": "펼치기",
|
||||
"Expected result (optional)": "예상 결과 (선택 사항)",
|
||||
"Explode": "분해",
|
||||
"Explode Text": "텍스트 분해",
|
||||
"Export Animation…": "애니메이션 내보내기…",
|
||||
"Export Bend Table…": "벤딩 표 내보내기…",
|
||||
"Export CSV…": "CSV 내보내기…",
|
||||
"Export Current View as DXF": "현재 뷰를 DXF로 내보내기",
|
||||
"Export Current View as DXF…": "현재 뷰를 DXF로 내보내기…",
|
||||
"Export DXF": "DXF 내보내기",
|
||||
"Export DXF…": "DXF 내보내기…",
|
||||
"Export JSON…": "JSON 내보내기…",
|
||||
"Export design library...": "설계 라이브러리 내보내기...",
|
||||
"Export hidden edges as dashed lines": "숨겨진 모서리를 파선으로 내보내기",
|
||||
"Expression": "표현식",
|
||||
"Extend to meet": "만날 때까지 연장",
|
||||
@@ -144,31 +183,41 @@
|
||||
"FILES": "파일",
|
||||
"FLAT": "전개",
|
||||
"Face": "면",
|
||||
"Face Appearance": "면 외관",
|
||||
"Face attachment lost": "면 부착 끊김",
|
||||
"Faces": "면",
|
||||
"Faces / edges / vertices": "면 / 모서리 / 꼭짓점",
|
||||
"Faces to split": "분할할 면",
|
||||
"Fastener": "체결 부품",
|
||||
"Favorites": "즐겨찾기",
|
||||
"Favourite": "Favourite",
|
||||
"Feature": "피처",
|
||||
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "많은 CAD 프로그램의 파일은 Y가 위쪽이라 여기(Z 위쪽)에서는 누워서 열립니다.",
|
||||
"Filter": "Filter",
|
||||
"Filter…": "필터…",
|
||||
"Find in Browser": "브라우저에서 찾기",
|
||||
"Finish": "완료",
|
||||
"First plane": "첫 번째 평면",
|
||||
"Fit and G2 corners require Constant radius.": "맞춤 및 G2 모서리에는 일정 반지름이 필요합니다.",
|
||||
"Fixed Face": "고정 면",
|
||||
"Flat Pattern": "플랫 패턴",
|
||||
"Flat size": "전개 크기",
|
||||
"Follow camera (Look At sketches)": "카메라 따라가기 (스케치 보기)",
|
||||
"Follow the camera (Look At sketches)": "카메라 따라가기 (스케치 보기)",
|
||||
"Font": "글꼴",
|
||||
"From": "시작",
|
||||
"From Bodies": "바디에서",
|
||||
"From edges": "시작 모서리",
|
||||
"Full Length": "전체 길이",
|
||||
"G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet.": "G2 모서리에는 일정 반지름과 정확한 맞춤이 필요합니다. G2 모서리 생성은 아직 지원되지 않습니다.",
|
||||
"Gap": "간격",
|
||||
"Grid": "격자",
|
||||
"HISTORY": "이력",
|
||||
"Height": "높이",
|
||||
"Hidden edges": "숨겨진 모서리",
|
||||
"History layout": "기록 레이아웃",
|
||||
"History names": "이력 이름",
|
||||
"Hole (optional)": "구멍 (선택 사항)",
|
||||
"Holes": "구멍",
|
||||
"Holes (edges on them)": "구멍 (구멍의 모서리)",
|
||||
"Home view": "홈 뷰",
|
||||
@@ -179,14 +228,20 @@
|
||||
"In place (surfaces)": "제자리에서 (서피스)",
|
||||
"In plane": "평면 내",
|
||||
"Inside": "안쪽",
|
||||
"Instant": "즉시",
|
||||
"Interface font": "인터페이스 글꼴",
|
||||
"Interferes": "간섭",
|
||||
"Invert": "반전",
|
||||
"Isocurve": "등매개 곡선",
|
||||
"Issue title": "이슈 제목",
|
||||
"Items": "항목",
|
||||
"JOINTS": "조인트",
|
||||
"Joint Origin1": "조인트 원점1",
|
||||
"Keep (points on it)": "유지할 부분(그 위의 점)",
|
||||
"Keep Tools": "공구 유지",
|
||||
"Keep of the other surface": "다른 곡면에서 유지할 부분",
|
||||
"Key a joint's value at this step": "이 단계에서 조인트 값을 키프레임으로 설정",
|
||||
"Keys": "키프레임",
|
||||
"LIBRARY": "라이브러리",
|
||||
"LINKS": "링크",
|
||||
"Later": "나중에",
|
||||
@@ -197,9 +252,14 @@
|
||||
"Letter Spacing": "글자 간격",
|
||||
"Library": "라이브러리",
|
||||
"Library file": "라이브러리 파일",
|
||||
"Limits": "제한",
|
||||
"Line Spacing": "줄 간격",
|
||||
"Line Type": "선 유형",
|
||||
"Listing the installed fonts…": "설치된 글꼴 목록을 불러오는 중…",
|
||||
"Load library": "라이브러리 불러오기",
|
||||
"Local library": "로컬 라이브러리",
|
||||
"Loop": "루프",
|
||||
"M5 or M5x0.5": "M5 또는 M5x0.5",
|
||||
"MANAGE": "관리",
|
||||
"MATERIALS": "재질",
|
||||
"MODEL": "모델",
|
||||
@@ -210,14 +270,20 @@
|
||||
"Matched restrictions check the final values.": "조건이 충족된 제한 규칙은 최종 값을 검사합니다.",
|
||||
"Material": "재질",
|
||||
"Max": "최대",
|
||||
"Maximum": "최대",
|
||||
"Mesh body": "메쉬 바디",
|
||||
"Mid Radius": "중간 반지름",
|
||||
"Middle double-click": "가운데 버튼 더블 클릭",
|
||||
"Min": "최소",
|
||||
"Minimum": "최소",
|
||||
"Mirror Plane": "대칭 평면",
|
||||
"Mitre": "연귀",
|
||||
"Model Parameters": "모델 매개변수",
|
||||
"Modeled": "모델링됨",
|
||||
"More toolbar panels": "도구 모음 패널 더 보기",
|
||||
"Motion": "모션",
|
||||
"Mouse gestures": "마우스 제스처",
|
||||
"Mouse gestures only work with the SolidWorks mouse mode (the wheel orbits, Ctrl + wheel pans).": "마우스 제스처는 SolidWorks 마우스 모드(휠로 회전, Ctrl + 휠로 이동)에서만 작동합니다.",
|
||||
"Move / Copy": "이동 / 복사",
|
||||
"Move Type": "이동 유형",
|
||||
"Move down": "아래로 이동",
|
||||
@@ -235,14 +301,19 @@
|
||||
"No matching appearance presets.": "일치하는 외관 프리셋이 없습니다.",
|
||||
"No matching appearances": "일치하는 외관 없음",
|
||||
"No visible bodies or sketches to export.": "표시된 바디나 스케치가 없어 내보낼 수 없습니다.",
|
||||
"Nominal diameter": "호칭 지름",
|
||||
"None": "없음",
|
||||
"Not available yet": "아직 사용할 수 없음",
|
||||
"Notch": "노치",
|
||||
"OUTPUT": "출력",
|
||||
"On your computer": "내 컴퓨터",
|
||||
"Opacity": "불투명도",
|
||||
"Open GitHub draft": "GitHub 초안 열기",
|
||||
"Open library...": "라이브러리 열기...",
|
||||
"Open local libraries in the desktop app. You can export this design's library here.": "로컬 라이브러리는 데스크톱 앱에서 여세요. 이 디자인의 라이브러리는 여기서 내보낼 수 있습니다.",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Rust로 만든 오픈 소스 매개변수 3D CAD. 라이선스: MIT OR Apache-2.0.",
|
||||
"Opens your browser and copies the report text for you to paste into GitHub.": "브라우저를 열고, GitHub에 붙여넣을 수 있도록 보고서 텍스트를 복사합니다.",
|
||||
"Opens your browser. Review and submit your report on GitHub.": "브라우저를 엽니다. GitHub에서 보고서를 검토하고 제출하세요.",
|
||||
"Operator": "연산자",
|
||||
"Orbit (right drag)": "회전 (오른쪽 드래그)",
|
||||
"Other side": "반대쪽",
|
||||
@@ -259,8 +330,11 @@
|
||||
"Pick the tool bodies": "공구 바디를 선택하세요",
|
||||
"Picked": "선택한 항목",
|
||||
"Picked point": "선택한 점",
|
||||
"Pitch": "피치",
|
||||
"Pivot": "피벗",
|
||||
"Place at the origin instead": "대신 원점에 배치",
|
||||
"Plastic Rules": "플라스틱 규칙",
|
||||
"Play the joint's motion in the view": "뷰에서 조인트의 모션 재생",
|
||||
"Playback": "재생",
|
||||
"Plot": "그래프",
|
||||
"Position": "위치",
|
||||
@@ -268,14 +342,18 @@
|
||||
"Position (G0)": "위치 (G0)",
|
||||
"Position along the edge, 0 at the start end, 1 at the other": "모서리를 따른 위치, 0은 시작점, 1은 반대쪽 끝",
|
||||
"Preferences › Design turns the offer back on": "환경 설정 › 디자인에서 다시 켤 수 있습니다",
|
||||
"Preview": "미리보기",
|
||||
"Preview Row": "행 미리보기",
|
||||
"Preview leaves the current design unchanged.": "미리보기는 현재 디자인을 변경하지 않습니다.",
|
||||
"Profile / Edges": "프로파일 / 모서리",
|
||||
"Profiles or curves": "프로파일 또는 곡선",
|
||||
"Profiles/Curves": "프로파일/곡선",
|
||||
"Quantity": "양",
|
||||
"RECENT": "최근",
|
||||
"REFERENCE": "참조",
|
||||
"RULED SURFACE": "룰드 서피스",
|
||||
"RULES": "규칙",
|
||||
"Radius": "반지름",
|
||||
"Radius Fit": "반지름 맞춤",
|
||||
"Ratio": "비율",
|
||||
"Reattach selected face": "선택한 면 다시 부착",
|
||||
@@ -284,6 +362,8 @@
|
||||
"Rectangular": "직사각형",
|
||||
"Reference face": "참조 면",
|
||||
"Remove Tools": "공구 제거",
|
||||
"Remove condition": "조건 제거",
|
||||
"Remove from Folder": "폴더에서 제거",
|
||||
"Remove from list": "목록에서 제거",
|
||||
"Remove override": "재정의 제거",
|
||||
"Remove private design details or paths before sharing.": "공유하기 전에 비공개 디자인 정보나 경로를 제거하세요.",
|
||||
@@ -291,12 +371,18 @@
|
||||
"Remove the column": "열 제거",
|
||||
"Remove the track": "트랙 제거",
|
||||
"Remove this point": "이 점 제거",
|
||||
"Rename Feature": "피처 이름 바꾸기",
|
||||
"Rename Folder": "폴더 이름 바꾸기",
|
||||
"Replace existing library file": "기존 라이브러리 파일 바꾸기",
|
||||
"Report a bug — review a screenshot and open a GitHub draft": "버그 신고 — 스크린샷을 검토하고 GitHub 초안 열기",
|
||||
"Report text copied. Paste it into GitHub and submit your report there.": "보고서 텍스트를 복사했습니다. GitHub에 붙여넣고 그곳에서 보고서를 제출하세요.",
|
||||
"Reports are public. A GitHub account is required.": "보고서는 공개됩니다. GitHub 계정이 필요합니다.",
|
||||
"Reset Toolbar": "도구 모음 재설정",
|
||||
"Resize Part": "부품 크기 조정",
|
||||
"Restore View": "뷰 복원",
|
||||
"Restore outer boundary": "외부 경계 복원",
|
||||
"Restore the inherited component or physical material appearance": "상속된 구성요소 또는 물리 재질 외관 복원",
|
||||
"Restore this studio's tools": "이 스튜디오의 도구 복원",
|
||||
"Result": "결과",
|
||||
"Retake screenshot": "스크린샷 다시 찍기",
|
||||
"Reverse": "반전",
|
||||
@@ -306,7 +392,14 @@
|
||||
"Revert Pos.": "위치 되돌리기",
|
||||
"Revert Position": "위치 복원",
|
||||
"Review the report and screenshot before opening GitHub.": "GitHub를 열기 전에 보고서와 스크린샷을 검토하세요.",
|
||||
"Review your report on GitHub and click Submit new issue to publish it.": "GitHub에서 보고서를 검토한 뒤 Submit new issue를 클릭해 게시하세요.",
|
||||
"Rib Length": "리브 길이",
|
||||
"Rib Offset": "리브 오프셋",
|
||||
"Rib Thickness": "리브 두께",
|
||||
"Ribs": "리브",
|
||||
"Rim Edge": "테두리 모서리",
|
||||
"Ring of tools on the right button": "오른쪽 버튼의 도구 링",
|
||||
"Roll Back Here": "여기로 롤백",
|
||||
"Roll History Marker Here": "여기로 히스토리 마커 이동",
|
||||
"Round": "둥글게",
|
||||
"Rule": "Rule",
|
||||
@@ -316,26 +409,34 @@
|
||||
"SURFACE QUALITY": "곡면 품질",
|
||||
"Save Rule": "규칙 저장",
|
||||
"Save View": "뷰 저장",
|
||||
"Save screenshot…": "스크린샷 저장…",
|
||||
"Save the study as an animated GIF, seen as the viewport shows it": "뷰포트에 보이는 대로 스터디를 애니메이션 GIF로 저장",
|
||||
"Saved preview. Geometry is being checked before editing.": "저장된 미리보기입니다. 편집하기 전에 기하를 검사하는 중입니다.",
|
||||
"Scale factor": "배율",
|
||||
"Scene": "장면",
|
||||
"Screenshot (optional)": "스크린샷 (선택 사항)",
|
||||
"Search": "검색",
|
||||
"Search commands": "명령 검색",
|
||||
"Search this library": "이 라이브러리 검색",
|
||||
"Second plane": "두 번째 평면",
|
||||
"Section": "단면",
|
||||
"Section position": "단면 위치",
|
||||
"Sections (one sketch each)": "단면 (각각 스케치 하나)",
|
||||
"Select Other": "다른 항목 선택",
|
||||
"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. Double-click the middle button to go Home (Preferences › Navigation).": "다른 항목 선택: 모델 위에서 왼쪽 버튼을 가만히 누르고 있거나(또는 오른쪽 클릭 › 다른 항목 선택…) 커서 아래의 모든 항목을 나열합니다. 같은 위치를 다시 클릭하면 다음 항목이 선택됩니다. 가운데 버튼을 더블 클릭하면 홈으로 이동합니다(환경설정 › 탐색).",
|
||||
"Select bodies (a face picks its body)": "바디 선택(면을 클릭하면 해당 바디 선택)",
|
||||
"Select bodies or faces instead": "대신 바디 또는 면을 선택하세요",
|
||||
"Select edges": "모서리 선택",
|
||||
"Selection filter": "선택 필터",
|
||||
"Semantic faces share their appearance across instances.": "시맨틱 면은 인스턴스 간에 외관을 공유합니다.",
|
||||
"Send your report on GitHub": "GitHub에서 보고서 보내기",
|
||||
"Shaded": "음영 처리",
|
||||
"Shape": "모양",
|
||||
"Sheet Rules": "시트 규칙",
|
||||
"Show Agent Cursor": "에이전트 커서 표시",
|
||||
"Show in folder": "폴더에서 보기",
|
||||
"Show the pointer an AI agent moves (#35)": "AI 에이전트가 움직이는 포인터 표시 (#35)",
|
||||
"Show tool at cursor": "커서 옆에 도구 표시",
|
||||
"Signed axes": "부호 있는 축",
|
||||
"Size": "크기",
|
||||
"Sketch constraints": "스케치 구속 조건",
|
||||
@@ -348,6 +449,7 @@
|
||||
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "이 디자인들이 저장되기 전에 SolidCraft가 종료되었습니다. 마지막 자동 저장본을 열 수 있습니다.",
|
||||
"Specialise": "특수화",
|
||||
"Speed": "속도",
|
||||
"Splitting tool": "분할 도구",
|
||||
"Square": "정사각형",
|
||||
"Stand it up": "세우기",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "세우기: X축 기준 90도 회전으로, 실행 취소할 수 있는 하나의 이동 피처로 적용됩니다.",
|
||||
@@ -357,17 +459,27 @@
|
||||
"Start continuity": "시작 연속성",
|
||||
"Step": "단계",
|
||||
"Steps": "단계",
|
||||
"Steps to reproduce (optional)": "재현 단계 (선택 사항)",
|
||||
"Stitch the kept pieces": "유지한 부분 재봉",
|
||||
"Stitch to both bodies": "두 바디에 재봉",
|
||||
"Studio": "스튜디오",
|
||||
"Study": "연구",
|
||||
"Style": "스타일",
|
||||
"Summary": "요약",
|
||||
"Suppress": "비활성화",
|
||||
"Suppressed": "억제됨",
|
||||
"Surface": "곡면",
|
||||
"Surface, or faces to turn over": "곡면 또는 뒤집을 면",
|
||||
"Swap Ends": "양 끝 바꾸기",
|
||||
"TRIM": "트림",
|
||||
"Tangent (G1)": "접선 (G1)",
|
||||
"Tangent Chain": "접선 체인",
|
||||
"Tangent chain": "접선 체인",
|
||||
"Tap drill diameter": "탭 드릴 지름",
|
||||
"Target": "대상",
|
||||
"Target Body": "대상 바디",
|
||||
"Tear": "찢김",
|
||||
"Technical details": "기술 세부 정보",
|
||||
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "점선 스케치는 저장된 평면 가이드입니다. 새 면을 선택하여 부착을 복구하세요.",
|
||||
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "저장된 작성자 인스턴스가 없습니다. 다시 연결하기 전에 올바른 작성자 배치를 선택하세요.",
|
||||
"Then": "Then",
|
||||
@@ -379,24 +491,32 @@
|
||||
"This child is shared. Deleting it removes it from every copy of its parent.": "이 하위 항목은 공유됩니다. 삭제하면 상위 항목의 모든 복사본에서 제거됩니다.",
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "이 하위 항목은 공유됩니다. 위치 변경은 상위 항목의 모든 복사본에 적용됩니다.",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "이 면은 안정적인 부착을 제공할 수 없습니다. 다른 면을 선택하거나 고정 평면을 명시적으로 사용하세요.",
|
||||
"This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "이 이미지는 아직 GitHub에 첨부되지 않았습니다. 복사하거나 저장한 뒤 GitHub에서 첨부하세요.",
|
||||
"To": "끝",
|
||||
"To Offset": "오프셋까지",
|
||||
"To edges": "끝 모서리",
|
||||
"Tolerance": "공차",
|
||||
"Too close": "너무 가까움",
|
||||
"Tool Bodies": "공구 바디",
|
||||
"Tools": "도구",
|
||||
"Tools, clockwise from the top": "도구 (위쪽부터 시계 방향)",
|
||||
"Trimming tool (plane, face or curves)": "트림 도구(평면, 면 또는 곡선)",
|
||||
"Turn off section analysis before exporting the view.": "뷰를 내보내기 전에 단면 분석을 끄세요.",
|
||||
"Type": "유형",
|
||||
"UNTRIM": "트림 해제",
|
||||
"Unit": "단위",
|
||||
"Unsuppress": "비활성화 해제",
|
||||
"Updating the preview": "미리보기 업데이트 중",
|
||||
"Use active author placement": "활성 작성자 배치 사용",
|
||||
"Use fixed plane": "고정 평면 사용",
|
||||
"Use saved fixed plane": "저장된 고정 평면 사용",
|
||||
"Use selected face as fixed plane": "선택한 면을 고정 평면으로 사용",
|
||||
"Use the SolidWorks mouse mode": "SolidWorks 마우스 모드 사용",
|
||||
"User Parameters": "사용자 매개변수",
|
||||
"VIEW": "보기",
|
||||
"VIEWS": "뷰",
|
||||
"Value": "Value",
|
||||
"Variable Radius": "가변 반지름",
|
||||
"Velocity": "속도",
|
||||
"Vertices": "꼭짓점",
|
||||
"View navigator": "뷰 탐색기",
|
||||
@@ -404,16 +524,50 @@
|
||||
"Wall": "벽",
|
||||
"Wall Location": "벽 위치",
|
||||
"Wall Thickness": "벽 두께",
|
||||
"What did you do just before the problem? A short list is enough": "문제가 생기기 직전에 무엇을 했나요? 짧은 목록이면 충분합니다",
|
||||
"What did you expect to see?": "어떤 결과를 기대했나요?",
|
||||
"What happened?": "무슨 일이 있었나요?",
|
||||
"When": "When",
|
||||
"Width": "너비",
|
||||
"Will fail": "실패 예정",
|
||||
"With gestures on, the right button no longer orbits the view: hold it and move to open the ring, then touch a tool's zone. A plain right-click still opens the menu.": "제스처를 켜면 오른쪽 버튼으로 더 이상 뷰가 회전하지 않습니다. 버튼을 누른 채 움직여 링을 열고 도구 영역에 닿으세요. 그냥 오른쪽 클릭하면 여전히 메뉴가 열립니다.",
|
||||
"X": "X",
|
||||
"X Angle": "X 각도",
|
||||
"X angle": "X 각도",
|
||||
"X distance": "X 거리",
|
||||
"Y": "Y",
|
||||
"Y Angle": "Y 각도",
|
||||
"Y angle": "Y 각도",
|
||||
"Y distance": "Y 거리",
|
||||
"Z": "Z",
|
||||
"Z Angle": "Z 각도",
|
||||
"Z Distance": "Z 거리",
|
||||
"Z angle": "Z 각도",
|
||||
"Z distance": "Z 거리",
|
||||
"Zebra": "제브라",
|
||||
"ai.prefs.availability": "어시스턴트 제어",
|
||||
"ai.prefs.background_commands": "에이전트 명령을 백그라운드에서 실행",
|
||||
"ai.prefs.follow_camera": "카메라를 스케치로 돌리기",
|
||||
"ai.prefs.follow_camera_off": "카메라 자동 따라가기가 꺼져 있습니다.",
|
||||
"ai.prefs.follow_camera_on": "에이전트가 만드는 스케치를 향해 카메라가 회전합니다.",
|
||||
"ai.prefs.intro": "에이전트, 권한, 그리고 작업이 표시되는 방식.",
|
||||
"ai.prefs.make_default": "기본값으로 사용",
|
||||
"ai.prefs.no_agents": "아직 에이전트가 없습니다. 에이전트를 추가하거나 새로 고쳐서 설치된 에이전트를 찾으세요.",
|
||||
"ai.prefs.no_saved_approvals": "저장된 승인이 없습니다.",
|
||||
"ai.prefs.override_all_warning": "이 에이전트는 묻지 않고 설계를 저장, 덮어쓰기, 내보내기 또는 교체할 수 있습니다.",
|
||||
"ai.prefs.permission_all": "모두 허용",
|
||||
"ai.prefs.permission_all_summary": "사용 가능한 SolidCraft 도구가 묻지 않고 실행됩니다.",
|
||||
"ai.prefs.permission_ask": "변경 전에 묻기",
|
||||
"ai.prefs.permission_ask_summary": "모든 설계 변경과 파일 작업을 승인합니다.",
|
||||
"ai.prefs.permission_edits": "모델 편집 허용",
|
||||
"ai.prefs.permission_edits_summary": "모델 편집은 실행되고, 파일 및 파괴적인 작업은 계속 묻습니다.",
|
||||
"ai.prefs.permission_scope": "SolidCraft 도구에만 적용됩니다. 에이전트의 셸과 파일 편집은 자체 권한을 사용합니다.",
|
||||
"ai.prefs.pointer_preview_hidden": "포인터 숨김",
|
||||
"ai.prefs.pointer_preview_speed": "에이전트 포인터 · {speed}",
|
||||
"ai.prefs.pointer_speed": "동작 속도",
|
||||
"ai.prefs.privacy_note": "선택한 에이전트 또는 엔드포인트만 대화를 받습니다. 엔드포인트 키는 시스템 키체인에 저장됩니다.",
|
||||
"ai.prefs.show_pointer": "에이전트 포인터 표시",
|
||||
"ai.prefs.turn_limit_hint": "메시지 하나에 어시스턴트가 수행할 수 있는 설계 변경 도구 호출의 최대 횟수입니다.",
|
||||
"angle, height": "각도, 높이",
|
||||
"appearance.dielectric_ior": "유전체 굴절률",
|
||||
"appearance.metallic": "금속성",
|
||||
@@ -887,6 +1041,12 @@
|
||||
"help.licences": "라이선스",
|
||||
"help.mit": "MIT 라이선스",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "선택한 모서리를 지정한 거리만큼 평평하게 깎습니다. 날카로운 모서리를 다듬거나 핀 또는 나사가 쉽게 들어가도록 할 때 사용합니다.",
|
||||
"help.solid.extrude": "스케치 프로파일을 지정한 거리만큼 3D로 밀어내 재료를 더하거나 제거합니다. 평면 윤곽을 블록, 보스 또는 포켓으로 만들 때 사용합니다.",
|
||||
"help.solid.fillet": "선택한 모서리를 지정한 반지름의 매끄러운 원호로 둥글게 만듭니다. 강도, 편의성 또는 외관을 위해 날카로운 모서리를 부드럽게 할 때 사용합니다.",
|
||||
"help.solid.hole": "면의 선택한 위치에 지정한 지름의 원형 구멍을 뚫습니다. 나사 여유 구멍, 핀 및 기타 단순한 원형 개구부에 사용합니다.",
|
||||
"help.solid.revolve": "스케치 프로파일을 축을 중심으로 회전시켜 둥근 솔리드를 만듭니다. 샤프트, 손잡이, 그릇 및 중심선을 기준으로 둥근 모든 부품에 사용합니다.",
|
||||
"help.solid.shell": "솔리드의 속을 비우고 지정한 두께의 벽을 남기며, 선택한 면을 열 수도 있습니다. 상자, 커버 및 기타 얇은 벽 부품에 사용합니다.",
|
||||
"help.third_party": "타사 라이선스",
|
||||
"home.back": "{name}(으)로 돌아가기",
|
||||
"home.crashes": "충돌 보고서",
|
||||
@@ -996,8 +1156,15 @@
|
||||
"motion.to_end": "끝으로",
|
||||
"motion.to_start": "처음으로",
|
||||
"motion.track_name": "트랙 {number}",
|
||||
"nav.alias.help": "Shift+Alt+왼쪽 드래그는 회전, Shift+Alt+가운데 드래그는 이동, Shift+Alt+오른쪽 드래그는 확대/축소입니다. 가운데 드래그로도 이동하고 오른쪽 드래그로도 회전합니다",
|
||||
"nav.blender.help": "가운데 드래그는 회전, Shift+가운데 드래그는 이동, Ctrl+가운데 드래그는 확대/축소입니다. 오른쪽 드래그는 카메라에 영향을 주지 않습니다",
|
||||
"nav.free_orbit.help": "마우스와 3D 마우스로 롤을 유지하며 양 극점을 넘어 회전합니다. Shift 축 스냅은 비활성화됩니다.",
|
||||
"nav.free_orbit.label": "자유 회전",
|
||||
"nav.fusion.help": "가운데 드래그는 이동, Shift+가운데 드래그는 회전, 오른쪽 드래그는 회전입니다",
|
||||
"nav.inventor.help": "가운데 드래그는 이동, Shift+가운데 드래그는 회전, Ctrl+가운데 드래그는 확대/축소, 오른쪽 드래그는 회전입니다",
|
||||
"nav.openscad.help": "빈 공간에서 왼쪽 드래그는 회전, Shift+왼쪽 드래그는 상자 선택, 스케치 형상 드래그는 편집입니다. 오른쪽 또는 가운데 드래그는 이동, Shift+오른쪽 또는 Shift+가운데 드래그는 확대/축소입니다",
|
||||
"nav.solidworks.help": "가운데 드래그는 회전, Ctrl+가운데 드래그는 이동, Shift+가운데 드래그는 확대/축소, 오른쪽 드래그는 회전입니다",
|
||||
"nav.tinkercad.help": "오른쪽 드래그는 회전, 가운데 또는 Shift+오른쪽 드래그는 이동입니다",
|
||||
"nav.touchpad.help": "Shift를 누른 채 포인터를 움직이면 화면이 이동하고, Alt/Option을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.",
|
||||
"nav.touchpad.label": "터치패드",
|
||||
"nav.touchpad.orbit": "회전 (Alt/Option을 누르고 이동, 마우스 버튼 불필요)",
|
||||
@@ -1020,8 +1187,130 @@
|
||||
"pattern.flip_direction": "방향 반전",
|
||||
"pick a hole to seat it, or OK places it at the origin": "안착시킬 구멍을 선택하거나, 확인을 누르면 원점에 배치됩니다",
|
||||
"pick one or two vertices, edges or faces": "꼭짓점, 모서리 또는 면을 하나나 두 개 선택하세요",
|
||||
"prefs.add_item": "항목 추가",
|
||||
"prefs.alt_frees_manipulators": "Alt / Option 키로 조작 화살표도 자유롭게 움직입니다",
|
||||
"prefs.anti_aliasing_note": "울퉁불퉁한 모서리를 부드럽게 합니다.",
|
||||
"prefs.applies_on_restart": "다시 시작하면 적용됩니다",
|
||||
"prefs.assistant": "어시스턴트",
|
||||
"prefs.axes": "축",
|
||||
"prefs.axis": "축",
|
||||
"prefs.bottom": "아래쪽",
|
||||
"prefs.browser": "브라우저",
|
||||
"prefs.built_in": "기본 제공",
|
||||
"prefs.button_left": "왼쪽 드래그",
|
||||
"prefs.button_middle": "가운데 드래그",
|
||||
"prefs.button_right": "오른쪽 드래그",
|
||||
"prefs.choose_font": "글꼴 파일 선택…",
|
||||
"prefs.click": "클릭",
|
||||
"prefs.contact_shading": "부드러운 접촉 음영",
|
||||
"prefs.contact_shading_note": "부품이 가까워지는 곳의 부드러운 음영.",
|
||||
"prefs.context_menu": "컨텍스트 메뉴",
|
||||
"prefs.context_menu_ring": "컨텍스트 메뉴 · 길게 누르면 방사형 메뉴",
|
||||
"prefs.cpu": "CPU (소프트웨어)",
|
||||
"prefs.crash_reports": "충돌 보고서 열기…",
|
||||
"prefs.crash_reports_note": "보고서는 문제를 재현하는 설계 및 명령과 함께 이 컴퓨터에 보관됩니다.",
|
||||
"prefs.ctrl_cmd": "Ctrl / Cmd",
|
||||
"prefs.curved_surfaces": "곡면",
|
||||
"prefs.curves": "곡면",
|
||||
"prefs.dead_zone_note": "빗금 친 영역이 데드존입니다. 그 안의 움직임은 무시됩니다.",
|
||||
"prefs.default": "기본값",
|
||||
"prefs.design": "설계 기본값",
|
||||
"prefs.design_note": "새 설계가 시작할 때의 설정입니다. 열려 있는 설계는 자체 설정을 유지합니다.",
|
||||
"prefs.devices": "3D 마우스",
|
||||
"prefs.display": "표시",
|
||||
"prefs.edges": "모서리 및 음영",
|
||||
"prefs.extend": "확장",
|
||||
"prefs.extra_navigation": "Ctrl+왼쪽 드래그로 빈 공간에서 회전하고, Space를 누른 채 이동합니다.",
|
||||
"prefs.follows_os": "운영 체제 설정을 따름",
|
||||
"prefs.font": "글꼴",
|
||||
"prefs.font_fallback": "선택한 글꼴에 없는 글리프는 기본 제공 글꼴로 대체되므로 빈 상자로 표시되는 레이블이 없습니다.",
|
||||
"prefs.font_sample": "The quick brown fox · Ø12.50 mm · 45.0° · 0123456789",
|
||||
"prefs.font_unreadable": "선택한 글꼴을 읽을 수 없습니다.",
|
||||
"prefs.general": "일반",
|
||||
"prefs.general_note": "언어, 저장 및 업데이트.",
|
||||
"prefs.gesture_orbit": "회전",
|
||||
"prefs.gesture_pan": "이동",
|
||||
"prefs.gesture_zoom": "확대/축소",
|
||||
"prefs.graphics": "그래픽",
|
||||
"prefs.graphics_note": "속도와 부드러움 중에서 선택합니다. 각 선택의 효과가 샘플에 표시됩니다.",
|
||||
"prefs.graphics_presets_note": "프리셋은 아래의 선택 항목을 한 번에 설정합니다.",
|
||||
"prefs.history": "이력",
|
||||
"prefs.hold_stop_snapping": "누르고 있으면 스냅 중지",
|
||||
"prefs.icons_only": "아이콘만",
|
||||
"prefs.imported_file": "가져온 파일",
|
||||
"prefs.input": "입력",
|
||||
"prefs.interface": "인터페이스",
|
||||
"prefs.interface_language": "인터페이스 언어",
|
||||
"prefs.interface_note": "SolidCraft의 모양과 각 부분의 위치입니다.",
|
||||
"prefs.interval_minutes": "{n}분",
|
||||
"prefs.labels": "레이블",
|
||||
"prefs.layout": "레이아웃",
|
||||
"prefs.layout_note": "컴팩트는 패널을 창 가장자리에 고정합니다. 브라우저 반투명은 플로팅에서 사용할 수 있습니다. 어시스턴트 위치는 어시스턴트 페이지에서 바꿀 수 있습니다.",
|
||||
"prefs.left": "왼쪽",
|
||||
"prefs.left_click": "왼쪽 클릭",
|
||||
"prefs.live_input": "실시간 입력",
|
||||
"prefs.live_preview": "실시간 미리보기",
|
||||
"prefs.minutes": "분",
|
||||
"prefs.minutes_suffix": " min",
|
||||
"prefs.mouse": "마우스 및 트랙패드",
|
||||
"prefs.mouse_heading": "마우스",
|
||||
"prefs.mouse_mapping": "마우스 매핑",
|
||||
"prefs.navigation": "탐색",
|
||||
"prefs.navigation_note": "마우스, 트랙패드, 3D 마우스로 뷰를 움직이는 방식입니다.",
|
||||
"prefs.not_on_gpu": "이 GPU에서는 사용할 수 없음",
|
||||
"prefs.on": "켜짐",
|
||||
"prefs.opaque": "불투명",
|
||||
"prefs.orbit_snap": "회전하는 동안 Shift를 누르면 축에 맞춰집니다.",
|
||||
"prefs.orientation": "방향",
|
||||
"prefs.overall_speed": "전체 속도",
|
||||
"prefs.preview_choices": "선택 항목 미리보기",
|
||||
"prefs.quality_default": "기본값",
|
||||
"prefs.quality_fastest": "가장 빠름",
|
||||
"prefs.quality_slowest": "가장 느림",
|
||||
"prefs.quality_smoother": "더 부드럽게",
|
||||
"prefs.reports_saved": "{n}개 저장됨",
|
||||
"prefs.right_click": "오른쪽 클릭",
|
||||
"prefs.saving": "자동 저장 및 복구",
|
||||
"prefs.scale_note": "창에서 Ctrl + / Ctrl −로도 조절합니다. 버튼은 100%에서 28 px입니다.",
|
||||
"prefs.scale_text": "배율 및 텍스트",
|
||||
"prefs.select": "선택",
|
||||
"prefs.select_one_item": "항목 하나 선택",
|
||||
"prefs.selecting": "선택",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift는 추가만, Ctrl / Cmd는 토글",
|
||||
"prefs.shift_add_note": "켬: Shift는 추가, Ctrl / Cmd는 토글. 끔(기본값): 둘 다 토글.",
|
||||
"prefs.shortcuts": "단축키",
|
||||
"prefs.sketch_selection": "스케치 및 선택",
|
||||
"prefs.sketch_selection_heading": "스케치 및 선택",
|
||||
"prefs.sketch_selection_note": "스케치가 스냅되는 방식과 클릭으로 선택하는 방식입니다.",
|
||||
"prefs.sketching": "스케치",
|
||||
"prefs.snap_drawing_note": "스케치 점을 그리거나 드래그하는 동안.",
|
||||
"prefs.source": "소스",
|
||||
"prefs.speed": "속도",
|
||||
"prefs.stand_up_note": "STEP 또는 IGES 파일이 Z가 위쪽인 상태로 열린 후 X축 기준 90도 회전을 제안합니다.",
|
||||
"prefs.status_done": "완료",
|
||||
"prefs.status_error": "오류",
|
||||
"prefs.status_selected": "선택됨",
|
||||
"prefs.status_warning": "경고",
|
||||
"prefs.supersampling_brief": "뷰를 더 크게 그린 뒤 줄여서 필터링합니다. 매우 선명하지만 더 무겁습니다.",
|
||||
"prefs.tab_accent_note": "강조 색상이 활성 탭을 따라갑니다",
|
||||
"prefs.theme_file": "테마 파일",
|
||||
"prefs.timeline_half_hour": "−30분",
|
||||
"prefs.timeline_now": "지금",
|
||||
"prefs.timeline_quarter_hour": "−15",
|
||||
"prefs.toggle_item": "항목을 추가하거나 다시 제거",
|
||||
"prefs.toolbar": "도구 모음",
|
||||
"prefs.translucent": "반투명",
|
||||
"prefs.under_browser": "브라우저 아래",
|
||||
"prefs.under_cursor": "커서 아래",
|
||||
"prefs.units_precision": "단위 및 정밀도",
|
||||
"prefs.updates": "업데이트",
|
||||
"prefs.view": "뷰 동작",
|
||||
"prefs.viewport": "뷰포트",
|
||||
"prefs.wheel": "휠",
|
||||
"prefs.wheel_trackpad": "휠 및 트랙패드",
|
||||
"prefs.workspace": "작업 공간",
|
||||
"prefs.zoom_toward_cursor": "확대/축소, 커서 방향으로",
|
||||
"properties.title": "속성: {name}",
|
||||
"recovery.days_ago": "{count}일 전",
|
||||
"recovery.detail": "{file} · 피처 {features}개 · {when} 자동 저장",
|
||||
@@ -1241,6 +1530,7 @@
|
||||
"thread.designation": "호칭",
|
||||
"thread.iso_metric": "ISO 미터",
|
||||
"thread.select_size": "크기 선택",
|
||||
"timeline.feature_hint": "두 번 클릭하여 편집 · 드래그하여 순서 변경 · 마우스 오른쪽 버튼을 클릭하여 더 보기",
|
||||
"timeline.group": "새 특성 폴더",
|
||||
"timeline.move_many": "타임라인 항목 이동",
|
||||
"timeline.move_to_group": "특성을 폴더로 이동",
|
||||
@@ -1490,6 +1780,8 @@
|
||||
"ui.agent_chat_waiting": "확인 대기 중",
|
||||
"ui.agent_cursor": "에이전트 커서",
|
||||
"ui.agent_cursor_speed": "에이전트 커서 속도",
|
||||
"ui.agent_off_thread": "에이전트가 명령을 실행하는 동안 창 반응성 유지",
|
||||
"ui.agent_off_thread_note": "에이전트의 느린 명령(AI 패널 또는 MCP 클라이언트에서 실행)은 창과 별도로 계산됩니다. 뷰를 계속 회전하며 살펴볼 수 있고, 뷰의 칩에서 하나씩 중지할 수 있습니다. 끄면 각 명령이 창의 스레드에서 실행되며, 창은 명령이 끝날 때까지 기다립니다.",
|
||||
"ui.ai_add": "추가",
|
||||
"ui.ai_add_agent": "에이전트 추가…",
|
||||
"ui.ai_add_agent_title": "에이전트 추가",
|
||||
@@ -1545,7 +1837,6 @@
|
||||
"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 기능 표시",
|
||||
@@ -1819,10 +2110,17 @@
|
||||
"ui.hideProfile": "윤곽 숨기기",
|
||||
"ui.history_back": "한 단계 뒤로",
|
||||
"ui.history_below_browser": "브라우저 아래",
|
||||
"ui.history_edge_hint": "가장자리를 더블클릭",
|
||||
"ui.history_first": "처음으로 이동",
|
||||
"ui.history_forward": "한 단계 앞으로",
|
||||
"ui.history_hide_names": "이름 숨기기",
|
||||
"ui.history_last": "끝으로 이동",
|
||||
"ui.history_marker_tip": "이력 마커: 드래그하여 모델을 되돌립니다. 새 피처는 여기에 추가됩니다",
|
||||
"ui.history_names": "이름",
|
||||
"ui.history_play": "끝까지 재생",
|
||||
"ui.history_show_names": "이름 표시",
|
||||
"ui.history_title": "기록",
|
||||
"ui.history_toggle_names": "피처 이름 표시/숨기기",
|
||||
"ui.hole": "구멍",
|
||||
"ui.hole_tap_type": "탭 유형",
|
||||
"ui.hole_type": "구멍 유형",
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
"Show Agent Cursor": "Papar Kursor Ejen",
|
||||
"Show in folder": "Papar dalam folder",
|
||||
"Show the pointer an AI agent moves (#35)": "Papar penuding yang digerakkan oleh ejen AI (#35)",
|
||||
"Show tool at cursor": "Tunjukkan alat di sebelah kursor",
|
||||
"Signed axes": "Paksi bertanda",
|
||||
"Size": "Saiz",
|
||||
"Sketch constraints": "Kekangan lakaran",
|
||||
@@ -1415,7 +1416,6 @@
|
||||
"ui.ai_permissions": "Kebenaran",
|
||||
"ui.ai_preset": "Pratetap",
|
||||
"ui.ai_remove": "Buang",
|
||||
"ui.ai_request_limit": "berhenti selepas 24 permintaan untuk satu mesej; hantar mesej lain untuk meneruskan",
|
||||
"ui.ai_reset": "Set Semula",
|
||||
"ui.ai_settings": "Tetapan AI…",
|
||||
"ui.ai_show_features": "Papar ciri AI",
|
||||
|
||||
@@ -334,6 +334,7 @@
|
||||
"Show Agent Cursor": "Agentcursor tonen",
|
||||
"Show in folder": "Tonen in map",
|
||||
"Show the pointer an AI agent moves (#35)": "De aanwijzer tonen die een AI-agent verplaatst (#35)",
|
||||
"Show tool at cursor": "Gereedschap bij de cursor tonen",
|
||||
"Signed axes": "Assen met teken",
|
||||
"Size": "Grootte",
|
||||
"Sketch constraints": "Schetsbeperkingen",
|
||||
@@ -1358,7 +1359,6 @@
|
||||
"ui.ai_permissions": "Machtigingen",
|
||||
"ui.ai_preset": "Voorinstelling",
|
||||
"ui.ai_remove": "Verwijderen",
|
||||
"ui.ai_request_limit": "gestopt na 24 verzoeken voor één bericht; stuur nog een bericht om verder te gaan",
|
||||
"ui.ai_reset": "Herstellen",
|
||||
"ui.ai_settings": "AI-instellingen…",
|
||||
"ui.ai_show_features": "AI-functies tonen",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Pending UI strings
|
||||
|
||||
A pull request that adds UI strings adds **one new file here** instead of editing
|
||||
`assets/locales/<locale>.json`. Two pull requests then never touch the same file, so they never
|
||||
conflict.
|
||||
|
||||
- Name it after the change, lowercase with dashes: `sketch-trim-hints.json`.
|
||||
- Contents: a map from locale code to `{"message id": "text"}`. Give English; add other locales
|
||||
you can translate (a locale you leave out falls back to English, as before):
|
||||
|
||||
```json
|
||||
{
|
||||
"en": { "sketch.trim.hint": "Click a segment to trim it" },
|
||||
"de": { "sketch.trim.hint": "Segment zum Kürzen anklicken" }
|
||||
}
|
||||
```
|
||||
|
||||
- The id must not already be in that locale's catalog, nor in another pending file; the build and
|
||||
`cargo xtask lists` fail otherwise. To change an existing message, edit the catalog itself.
|
||||
- Don't sort or fold anything. The app, `cargo xtask i18n` and the tests read these files as part of
|
||||
the catalogs, so your strings work in your own pull request.
|
||||
- After a merge, the Generated files workflow runs `cargo xtask fold-pending` on `main`: it adds the
|
||||
messages to the catalogs (sorted, one per line) and deletes the files. Never run it in a pull
|
||||
request.
|
||||
@@ -352,6 +352,7 @@
|
||||
"Show Agent Cursor": "Pokaż kursor agenta",
|
||||
"Show in folder": "Pokaż w folderze",
|
||||
"Show the pointer an AI agent moves (#35)": "Pokaż wskaźnik poruszany przez agenta AI (#35)",
|
||||
"Show tool at cursor": "Pokaż narzędzie przy kursorze",
|
||||
"Signed axes": "Osie ze znakiem",
|
||||
"Size": "Rozmiar",
|
||||
"Sketch constraints": "Wiązania szkicu",
|
||||
@@ -1601,7 +1602,6 @@
|
||||
"ui.ai_permissions": "Uprawnienia",
|
||||
"ui.ai_preset": "Ustawienie wstępne",
|
||||
"ui.ai_remove": "Usuń",
|
||||
"ui.ai_request_limit": "zatrzymano po 24 żądaniach dla jednej wiadomości; wyślij kolejną wiadomość, aby kontynuować",
|
||||
"ui.ai_reset": "Resetuj",
|
||||
"ui.ai_settings": "Ustawienia AI…",
|
||||
"ui.ai_show_features": "Pokaż funkcje AI",
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
"Show Agent Cursor": "Mostrar cursor do agente",
|
||||
"Show in folder": "Mostrar na pasta",
|
||||
"Show the pointer an AI agent moves (#35)": "Mostrar o ponteiro que um agente de IA move (#35)",
|
||||
"Show tool at cursor": "Mostrar a ferramenta junto ao cursor",
|
||||
"Signed axes": "Eixos com sinal",
|
||||
"Size": "Tamanho",
|
||||
"Sketch constraints": "Restrições do esboço",
|
||||
@@ -1422,7 +1423,6 @@
|
||||
"ui.ai_permissions": "Permissões",
|
||||
"ui.ai_preset": "Predefinição",
|
||||
"ui.ai_remove": "Remover",
|
||||
"ui.ai_request_limit": "parou após 24 requisições para uma mensagem; envie outra mensagem para continuar",
|
||||
"ui.ai_reset": "Redefinir",
|
||||
"ui.ai_settings": "Configurações de IA…",
|
||||
"ui.ai_show_features": "Mostrar recursos de IA",
|
||||
|
||||
@@ -334,6 +334,7 @@
|
||||
"Show Agent Cursor": "Afișează cursorul agentului",
|
||||
"Show in folder": "Afișează în dosar",
|
||||
"Show the pointer an AI agent moves (#35)": "Afișează indicatorul pe care îl mută un agent AI (#35)",
|
||||
"Show tool at cursor": "Afișează instrumentul lângă cursor",
|
||||
"Signed axes": "Axe cu semn",
|
||||
"Size": "Dimensiune",
|
||||
"Sketch constraints": "Constrângerile schiței",
|
||||
@@ -1358,7 +1359,6 @@
|
||||
"ui.ai_permissions": "Permisiuni",
|
||||
"ui.ai_preset": "Presetare",
|
||||
"ui.ai_remove": "Elimină",
|
||||
"ui.ai_request_limit": "oprit după 24 de cereri pentru un mesaj; trimiteți alt mesaj pentru a continua",
|
||||
"ui.ai_reset": "Resetează",
|
||||
"ui.ai_settings": "Setări AI…",
|
||||
"ui.ai_show_features": "Afișează funcțiile AI",
|
||||
|
||||
@@ -334,6 +334,7 @@
|
||||
"Show Agent Cursor": "Показывать курсор агента",
|
||||
"Show in folder": "Показать в папке",
|
||||
"Show the pointer an AI agent moves (#35)": "Показывать указатель, которым двигается ИИ-агент (#35)",
|
||||
"Show tool at cursor": "Показывать инструмент у курсора",
|
||||
"Signed axes": "Знаковые оси",
|
||||
"Size": "Размер",
|
||||
"Sketch constraints": "Ограничения эскиза",
|
||||
@@ -1414,7 +1415,6 @@
|
||||
"ui.ai_permissions": "Разрешения",
|
||||
"ui.ai_preset": "Пресет",
|
||||
"ui.ai_remove": "Удалить",
|
||||
"ui.ai_request_limit": "остановлено после 24 запросов на одно сообщение; отправьте ещё одно сообщение, чтобы продолжить",
|
||||
"ui.ai_reset": "Сбросить",
|
||||
"ui.ai_settings": "Настройки ИИ…",
|
||||
"ui.ai_show_features": "Показывать функции ИИ",
|
||||
|
||||
@@ -334,6 +334,7 @@
|
||||
"Show Agent Cursor": "Visa agentmarkör",
|
||||
"Show in folder": "Visa i mapp",
|
||||
"Show the pointer an AI agent moves (#35)": "Visa pekaren som en AI-agent flyttar (#35)",
|
||||
"Show tool at cursor": "Visa verktyget vid markören",
|
||||
"Signed axes": "Axlar med tecken",
|
||||
"Size": "Storlek",
|
||||
"Sketch constraints": "Skissvillkor",
|
||||
@@ -1358,7 +1359,6 @@
|
||||
"ui.ai_permissions": "Behörigheter",
|
||||
"ui.ai_preset": "Förinställning",
|
||||
"ui.ai_remove": "Ta bort",
|
||||
"ui.ai_request_limit": "stoppades efter 24 förfrågningar för ett meddelande; skicka ett nytt meddelande för att fortsätta",
|
||||
"ui.ai_reset": "Återställ",
|
||||
"ui.ai_settings": "AI-inställningar…",
|
||||
"ui.ai_show_features": "Visa AI-funktioner",
|
||||
|
||||
@@ -334,6 +334,7 @@
|
||||
"Show Agent Cursor": "Ajan imlecini göster",
|
||||
"Show in folder": "Klasörde göster",
|
||||
"Show the pointer an AI agent moves (#35)": "Bir AI ajanının hareket ettirdiği imleci göster (#35)",
|
||||
"Show tool at cursor": "Aracı imlecin yanında göster",
|
||||
"Signed axes": "İşaretli eksenler",
|
||||
"Size": "Boyut",
|
||||
"Sketch constraints": "Eskiz kısıtlamaları",
|
||||
@@ -1414,7 +1415,6 @@
|
||||
"ui.ai_permissions": "İzinler",
|
||||
"ui.ai_preset": "Şablon",
|
||||
"ui.ai_remove": "Kaldır",
|
||||
"ui.ai_request_limit": "bir mesaj için 24 istekten sonra durduruldu; devam etmek için başka bir mesaj gönderin",
|
||||
"ui.ai_reset": "Sıfırla",
|
||||
"ui.ai_settings": "AI ayarları…",
|
||||
"ui.ai_show_features": "AI özelliklerini göster",
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
"Show Agent Cursor": "Показати курсор агента",
|
||||
"Show in folder": "Показати в теці",
|
||||
"Show the pointer an AI agent moves (#35)": "Показувати вказівник, яким рухає ШІ-агент (#35)",
|
||||
"Show tool at cursor": "Показувати інструмент біля курсора",
|
||||
"Signed axes": "Підписані осі",
|
||||
"Size": "Розмір",
|
||||
"Sketch constraints": "Обмеження ескізу",
|
||||
@@ -1415,7 +1416,6 @@
|
||||
"ui.ai_permissions": "Дозволи",
|
||||
"ui.ai_preset": "Набір",
|
||||
"ui.ai_remove": "Видалити",
|
||||
"ui.ai_request_limit": "зупинено після 24 запитів на одне повідомлення; надішліть інше повідомлення, щоб продовжити",
|
||||
"ui.ai_reset": "Скинути",
|
||||
"ui.ai_settings": "Налаштування ШІ…",
|
||||
"ui.ai_show_features": "Показувати функції ШІ",
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
"Show Agent Cursor": "Hiển thị con trỏ của AI Agent",
|
||||
"Show in folder": "Hiển thị trong thư mục",
|
||||
"Show the pointer an AI agent moves (#35)": "Hiển thị con trỏ do AI Agent điều khiển (#35)",
|
||||
"Show tool at cursor": "Hiển thị công cụ cạnh con trỏ",
|
||||
"Signed axes": "Các trục có dấu (+/-)",
|
||||
"Size": "Kích thước",
|
||||
"Sketch constraints": "Ràng buộc phác thảo",
|
||||
@@ -1415,7 +1416,6 @@
|
||||
"ui.ai_permissions": "Quyền hạn",
|
||||
"ui.ai_preset": "Mẫu cấu hình sẵn",
|
||||
"ui.ai_remove": "Xóa",
|
||||
"ui.ai_request_limit": "đã dừng sau 24 yêu cầu cho một tin nhắn; gửi tin nhắn khác để tiếp tục",
|
||||
"ui.ai_reset": "Đặt lại",
|
||||
"ui.ai_settings": "AI cài đặt…",
|
||||
"ui.ai_show_features": "Hiển thị các tính năng AI",
|
||||
|
||||
@@ -334,6 +334,7 @@
|
||||
"Show Agent Cursor": "顯示代理游標",
|
||||
"Show in folder": "在資料夾中顯示",
|
||||
"Show the pointer an AI agent moves (#35)": "顯示 AI 代理移動的指標(#35)",
|
||||
"Show tool at cursor": "在游標旁顯示工具",
|
||||
"Signed axes": "帶符號軸",
|
||||
"Size": "大小",
|
||||
"Sketch constraints": "草圖拘束條件",
|
||||
@@ -1358,7 +1359,6 @@
|
||||
"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 功能",
|
||||
|
||||
+64
-1
@@ -8,6 +8,7 @@
|
||||
"A Single hole snaps to the middle of its face and to the centres of its round edges": "单个孔捕捉到其所在面的中心和圆形边的中心",
|
||||
"A new configuration, a copy of the active one": "新建配置,为当前配置的副本",
|
||||
"A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out": "已放置的单个孔带有可拖动的控点,再次选择该面会将孔移到该处而不是删除它",
|
||||
"A short title for the problem (optional)": "问题的简短标题(可选)",
|
||||
"ARRANGE": "排列",
|
||||
"About SolidCraft": "关于 SolidCraft",
|
||||
"Acceleration": "加速度",
|
||||
@@ -21,6 +22,7 @@
|
||||
"Add Mid Radius": "添加中间半径",
|
||||
"Add Row": "添加行",
|
||||
"Add a tool": "添加工具",
|
||||
"Add bend": "添加折弯",
|
||||
"Add condition": "添加条件",
|
||||
"Add or edit rule": "添加或编辑规则",
|
||||
"Add to New Folder": "添加到新文件夹",
|
||||
@@ -35,7 +37,9 @@
|
||||
"Also deletes": "同时删除",
|
||||
"Anchor": "基准点",
|
||||
"Angle 2": "角度 2",
|
||||
"Angle Display": "角度显示",
|
||||
"Animate": "动画",
|
||||
"Another bend on the far edge of the last flange": "在最后一个法兰的远端边上再加一道折弯",
|
||||
"App/system information and command settings are included. Review private details before sharing.": "报告包含应用和系统信息以及命令设置。共享前请检查私人信息。",
|
||||
"Appearance name": "外观名称",
|
||||
"Applied Radius": "应用半径",
|
||||
@@ -52,6 +56,7 @@
|
||||
"BOUNDARY SURFACE": "边界曲面",
|
||||
"BROWSER": "浏览器",
|
||||
"Bar": "扁钢",
|
||||
"Bend": "折弯角度",
|
||||
"Bend Edges": "折弯边",
|
||||
"Bend Line Position": "弯折线位置",
|
||||
"Bend Position": "折弯位置",
|
||||
@@ -218,6 +223,7 @@
|
||||
"Fit and G2 corners require Constant radius.": "拟合和 G2 拐角需要 Constant radius。",
|
||||
"Fixed Face": "固定面",
|
||||
"Flange thickness": "翼缘厚度",
|
||||
"Flanges": "法兰",
|
||||
"Flat Pattern": "展开图",
|
||||
"Flat size": "平板尺寸",
|
||||
"Follow camera (Look At sketches)": "跟随相机(注视草图)",
|
||||
@@ -227,6 +233,8 @@
|
||||
"From Bodies": "从实体创建",
|
||||
"From edges": "起始边",
|
||||
"Full Length": "全长",
|
||||
"Full thread depth": "完整螺纹深度",
|
||||
"Further Bends": "更多折弯",
|
||||
"G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet.": "G2 拐角需要恒定半径和 Exact fit。尚不支持构建 G2 拐角。",
|
||||
"Gap": "间隙",
|
||||
"Grid": "网格",
|
||||
@@ -247,6 +255,7 @@
|
||||
"In This Design": "在此设计中",
|
||||
"In place (surfaces)": "原位(曲面)",
|
||||
"In plane": "在平面内",
|
||||
"Included": "夹角",
|
||||
"Inside": "内侧",
|
||||
"Instant": "即时",
|
||||
"Interface font": "界面字体",
|
||||
@@ -305,6 +314,7 @@
|
||||
"Mitre": "斜接",
|
||||
"Model Parameters": "模型参数",
|
||||
"Modeled": "已建模",
|
||||
"More themes": "更多主题",
|
||||
"More toolbar panels": "更多工具栏面板",
|
||||
"Motion": "运动",
|
||||
"Mouse gestures": "鼠标手势",
|
||||
@@ -332,6 +342,7 @@
|
||||
"Nominal diameter": "公称直径",
|
||||
"None": "无",
|
||||
"Not available yet": "暂不可用",
|
||||
"Not saved yet": "尚未保存",
|
||||
"Notch": "缺口",
|
||||
"OUTPUT": "输出",
|
||||
"Of": "的值",
|
||||
@@ -347,6 +358,7 @@
|
||||
"Orbit (right drag)": "旋转(右键拖动)",
|
||||
"Other side": "另一侧",
|
||||
"Other surface (mutual trim)": "另一曲面(相互修剪)",
|
||||
"Other way": "反方向",
|
||||
"Outer radius": "外圆角半径",
|
||||
"Outside": "外侧",
|
||||
"Overall length": "总长",
|
||||
@@ -376,6 +388,7 @@
|
||||
"Preview": "预览",
|
||||
"Preview Row": "预览行",
|
||||
"Preview leaves the current design unchanged.": "预览不会更改当前设计。",
|
||||
"Previous themes": "上一组主题",
|
||||
"Profile / Edges": "轮廓 / 边",
|
||||
"Profiles or curves": "轮廓或曲线",
|
||||
"Profiles/Curves": "轮廓/曲线",
|
||||
@@ -402,6 +415,7 @@
|
||||
"Remove screenshot": "移除截图",
|
||||
"Remove the column": "移除此列",
|
||||
"Remove the track": "移除轨迹",
|
||||
"Remove this bend": "移除此折弯",
|
||||
"Remove this point": "移除此点",
|
||||
"Rename Feature": "重命名特征",
|
||||
"Rename Folder": "重命名文件夹",
|
||||
@@ -443,6 +457,7 @@
|
||||
"Rules run from top to bottom. Matched drives change effective values.": "规则从上到下依次运行。满足条件的驱动规则更改有效值。",
|
||||
"SECTION": "剖面",
|
||||
"SURFACE QUALITY": "曲面质量",
|
||||
"Same way": "同方向",
|
||||
"Save Rule": "保存规则",
|
||||
"Save View": "保存视图",
|
||||
"Save screenshot…": "保存屏幕截图…",
|
||||
@@ -471,8 +486,10 @@
|
||||
"Shape": "形状",
|
||||
"Sheet Rules": "钣金规则",
|
||||
"Show Agent Cursor": "显示代理光标",
|
||||
"Show ghost": "显示虚影",
|
||||
"Show in folder": "在文件夹中显示",
|
||||
"Show the pointer an AI agent moves (#35)": "显示 AI 代理移动的指针(#35)",
|
||||
"Show tool at cursor": "在光标旁显示工具",
|
||||
"Signed axes": "带符号轴",
|
||||
"Size": "大小",
|
||||
"Sketch constraints": "草图约束",
|
||||
@@ -529,6 +546,7 @@
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "此子项是共享的。位置更改会应用到其父项的每个副本。",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "此面无法提供持久附着。请选择其他面或显式使用固定平面。",
|
||||
"This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "此图片尚未附加到GitHub。请复制或保存图片,然后在GitHub中附加。",
|
||||
"Thread depth": "螺纹深度",
|
||||
"Thread diameter": "螺纹直径",
|
||||
"To": "终点",
|
||||
"To Offset": "偏移",
|
||||
@@ -588,10 +606,13 @@
|
||||
"Z angle": "Z 角度",
|
||||
"Z distance": "Z 距离",
|
||||
"Zebra": "斑马纹",
|
||||
"ai.connected_toast": "SolidCraft 现已可在 {clients} 中使用。请重启它们(设置 > 助手)。",
|
||||
"ai.prefs.allow_control": "允许 AI 代理连接(控制通道 / MCP)",
|
||||
"ai.prefs.allow_control_note": "仅在本机开放控制通道(127.0.0.1,端口 27878 或下一个空闲端口)。连接的程序还必须出示一个每次启动时新生成、只有你的用户账户能读取的秘密令牌。下次启动 SolidCraft 时生效。关闭:除非用 --control 启动,否则不监听任何端口。",
|
||||
"ai.prefs.availability": "助手控制",
|
||||
"ai.prefs.background_commands": "在后台执行代理命令",
|
||||
"ai.prefs.connect_apps": "让此电脑上的 AI 应用可以使用 SolidCraft",
|
||||
"ai.prefs.connect_apps_note": "SolidCraft 启动时,会把自己作为 MCP 服务器添加到此电脑上找到的 AI 应用(OpenCode、Claude Desktop、Claude Code、Cursor、Codex、Gemini CLI、Copilot CLI、Windsurf、Zed、VS Code)。只添加或更新 solidcraft 条目;你修改过的条目不会被改动,文件会先备份。关闭后,会删除它添加的条目。",
|
||||
"ai.prefs.follow_camera": "将相机转向草图",
|
||||
"ai.prefs.follow_camera_off": "相机自动跟随已关闭。",
|
||||
"ai.prefs.follow_camera_on": "相机将转向代理创建的草图。",
|
||||
@@ -756,6 +777,7 @@
|
||||
"cmd.inspect_interference": "干涉",
|
||||
"cmd.inspect_isocurve": "等参线分析",
|
||||
"cmd.inspect_minimum_radius": "最小半径分析",
|
||||
"cmd.inspect_parameter_sweep": "参数扫描",
|
||||
"cmd.inspect_silhouette": "轮廓",
|
||||
"cmd.inspect_zebra": "斑马纹分析",
|
||||
"cmd.joint_as_built": "现状接头",
|
||||
@@ -881,6 +903,7 @@
|
||||
"cmd.sheet_fold": "折叠",
|
||||
"cmd.sheet_hem": "卷边",
|
||||
"cmd.sheet_manage_rules": "钣金规则",
|
||||
"cmd.sheet_path_flange": "路径法兰",
|
||||
"cmd.sheet_perforate": "冲孔",
|
||||
"cmd.sheet_refold": "重新折弯",
|
||||
"cmd.sheet_rules": "列出钣金规则",
|
||||
@@ -1073,7 +1096,18 @@
|
||||
"dialog.selection_guidance": "选择输入项进行修改。点击已选对象可取消选择;按住 Ctrl/Cmd 点击也可切换选择。检查预览后,再按“确定”。",
|
||||
"dialog.selection_step": "第 {step} 步,共 {total} 步:选择{input}",
|
||||
"dialog.step": "步骤",
|
||||
"document.changed_on_disk": "{name} 已在磁盘上更改",
|
||||
"document.changed_on_disk_detail": "自您打开或上次保存 {name} 以来,它已被其他程序或计算机更改。覆盖会用您的更改替换这些更改;保存副本则两者都保留。",
|
||||
"document.copy_name": "{name} 副本",
|
||||
"document.open_anyway": "仍然打开",
|
||||
"document.open_elsewhere": "{name} 已被打开",
|
||||
"document.open_elsewhere_detail": "{name} 已在 {who} 上的 SolidCraft {app} 中打开({when}打开)。如果双方都保存,其中一方的工作会丢失。以只读方式打开即可查看而不会覆盖它。",
|
||||
"document.open_read_only": "以只读方式打开",
|
||||
"document.opened_read_only": "已以只读方式打开:保存时会要求新文件名",
|
||||
"document.overwrite": "覆盖",
|
||||
"document.read_only_save": "此设计以只读方式打开:请另存为新文件",
|
||||
"document.save_changes": "保存对 {name} 的更改?",
|
||||
"document.save_copy": "保存副本…",
|
||||
"document.unsaved_changes": "{name} 有未保存的更改。如果不保存,将丢失这些更改。",
|
||||
"drive.stopped_by_contact": "停在 {value}:{a} 会穿过 {b}",
|
||||
"drive.stopped_by_loop": "停在 {value}:经过 {joint} 的环路无法再闭合",
|
||||
@@ -1109,17 +1143,25 @@
|
||||
"help.apache": "Apache 许可证 2.0",
|
||||
"help.attributions": "署名信息 (ATTRIBUTION.md)",
|
||||
"help.build": "版本 {version} · 提交 {commit} · 构建日期 {date}",
|
||||
"help.connect_note": "请重启各个助手以加载 SolidCraft 服务器。每个被修改的文件都已备份为 .bak。",
|
||||
"help.connect_title": "连接 AI 助手",
|
||||
"help.font_details": "界面使用 egui 的内置字体(Ubuntu Light 和 Hack,采用 Ubuntu Font Licence 和 MIT/Bitstream Vera 许可证;Noto Emoji 和表情图标字体,采用 OFL 和 MIT 许可证)。草图文本使用 Liberation Sans(SIL OFL 1.1)。The Crafting Apps 的可选字体包 craft-fonts(BIZ UDPGothic、Shippori Mincho、BIZ UDMincho、Noto Sans CJK SC、Noto Sans Arabic;SIL OFL 1.1)未随 SolidCraft 构建版本捆绑提供。",
|
||||
"help.fonts": "字体",
|
||||
"help.licences": "许可证",
|
||||
"help.mit": "MIT 许可证",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "用指定的距离在所选边上切出一个平直的斜面。用于去除锐边,或使销钉、螺钉更容易插入。",
|
||||
"help.solid.combine": "将多个实体合并、从目标实体中切除,或只保留它们共有的体积,还可以保留工具实体。用于合并零件、用一个形体切割另一个形体,或找出两个实体重叠的位置。",
|
||||
"help.solid.extrude": "将草图轮廓按指定距离拉伸为三维形体,可以增加或去除材料。用于把平面轮廓变成方块、凸台或凹槽。",
|
||||
"help.solid.fillet": "用指定半径的平滑圆弧将所选的边倒圆。用于出于强度、手感或外观的考虑而柔化锐边。",
|
||||
"help.solid.hole": "在面上的指定位置钻出指定直径的圆孔。用于螺钉过孔、销孔等简单的圆形开口。",
|
||||
"help.solid.loft": "在两个或更多草图轮廓之间,或从轮廓到一个点,生成平滑过渡的实体。用于过渡段、漏斗、手柄等截面沿长度变化的形体。",
|
||||
"help.solid.mirror": "将所选实体或特征相对于一个平面生成镜像副本,还可以与原件合并。用于对称零件,只需建一半即可得到两侧。",
|
||||
"help.solid.pattern.circular": "将所选对象按指定的数量和总角度绕轴重复排列。用于螺栓圆周分布、辐条、散热片和轮齿。",
|
||||
"help.solid.pattern.rectangular": "将所选对象按指定的数量和间距沿一个或两个方向重复排列。用于成排或成网格的孔、加强筋、销钉或支脚。",
|
||||
"help.solid.revolve": "将草图轮廓绕一条轴旋转,生成圆形的实体。用于轴、旋钮、碗等围绕中心线呈圆形的零件。",
|
||||
"help.solid.shell": "将实体掏空,留下指定厚度的壁,还可以打开所选的面。用于盒子、盖子等薄壁零件。",
|
||||
"help.solid.sweep": "沿路径拖动草图轮廓生成实体。用于管道、导轨、手柄等截面恒定且沿曲线延伸的零件。",
|
||||
"help.third_party": "第三方许可证",
|
||||
"home.back": "返回 {name}",
|
||||
"home.crashes": "崩溃报告",
|
||||
@@ -1301,8 +1343,19 @@
|
||||
"pattern.direction_1": "方向 1",
|
||||
"pattern.direction_2": "方向 2",
|
||||
"pattern.flip_direction": "反转方向",
|
||||
"perf.click_for_details": "点击查看详情:帧耗时图、直方图和 GPU 绘制的内容",
|
||||
"perf.draw_calls": "绘制调用",
|
||||
"perf.line_segments": "线段",
|
||||
"perf.low_1": "1% 最低",
|
||||
"perf.renderer": "渲染器",
|
||||
"perf.scene_build": "场景构建",
|
||||
"perf.triangles": "三角形",
|
||||
"perf.ui_time": "界面耗时",
|
||||
"perf.viewport": "视图",
|
||||
"perf.worst": "最差",
|
||||
"pick a hole to seat it, or OK places it at the origin": "选择一个孔放置,或单击确定放置在原点",
|
||||
"pick one or two vertices, edges or faces": "选择一个或两个顶点、边或面",
|
||||
"pick the path": "选择路径",
|
||||
"prefs.add_item": "添加项目",
|
||||
"prefs.alt_frees_manipulators": "Alt / Option 也可自由拖动操纵器箭头",
|
||||
"prefs.anti_aliasing_note": "平滑锯齿边缘。",
|
||||
@@ -2052,7 +2105,9 @@
|
||||
"search.try": "输入命令名称、此设计中的特征或参数。要带值运行命令,请输入其ID和JSON对象:{example}",
|
||||
"search.valid": "有效",
|
||||
"section.off_hint": "未选择平面:按“确定”将移除剖面。",
|
||||
"set by the sketch's lines": "由草图的线条决定",
|
||||
"settings.display_quality_note": "仅影响显示,不影响设计和导出。",
|
||||
"settings.frame_rate_details_note": "添加最近各帧耗时的图表(叠加绘制的三角形数)、帧耗时直方图以及 GPU 绘制的内容。点击视图中的帧率可显示或隐藏。",
|
||||
"settings.frame_rate_note": "在视图右下角显示每秒帧数。",
|
||||
"settings.measure_axis_distances": "显示 X/Y/Z 距离",
|
||||
"settings.measure_axis_distances_help": "测量对话框打开时,在总距离之外显示沿世界坐标轴方向的尺寸线和距离。",
|
||||
@@ -2107,6 +2162,7 @@
|
||||
"timeline.delete_after_marker_one": "删除历史标记之后的 1 个特征",
|
||||
"timeline.delete_after_marker_other": "删除历史标记之后的 {count} 个特征",
|
||||
"timeline.delete_many": "删除所选的 {count} 个特征",
|
||||
"timeline.feature_hint": "双击编辑 · 拖动以重新排序 · 右键查看更多操作",
|
||||
"timeline.group": "新建特征文件夹",
|
||||
"timeline.move_many": "移动时间线项目",
|
||||
"timeline.move_to_group": "将特征移至文件夹",
|
||||
@@ -2254,6 +2310,7 @@
|
||||
"ui.agent_chat_auth_title": "登录智能体",
|
||||
"ui.agent_chat_cancel": "取消",
|
||||
"ui.agent_chat_changes": "{n} 项更改,一步撤销",
|
||||
"ui.agent_chat_changes_n": "{n} 项更改",
|
||||
"ui.agent_chat_changes_of": "更改 {n}/{limit}",
|
||||
"ui.agent_chat_close": "关闭",
|
||||
"ui.agent_chat_command": "智能体命令",
|
||||
@@ -2358,6 +2415,7 @@
|
||||
"ui.agent_chat_stop_tip": "停止智能体及其启动的所有进程",
|
||||
"ui.agent_chat_stop_turn_tip": "停止智能体(Esc)。排队的消息仍保持排队。",
|
||||
"ui.agent_chat_turn_limit": "每条消息的更改数",
|
||||
"ui.agent_chat_turn_unlimited": "不限",
|
||||
"ui.agent_chat_undo_changes": "撤销这些更改",
|
||||
"ui.agent_chat_undo_label": "智能体聊天:{message}",
|
||||
"ui.agent_chat_undone": "(已撤销)",
|
||||
@@ -2426,7 +2484,9 @@
|
||||
"ui.ai_preset": "预设",
|
||||
"ui.ai_protocol": "协议",
|
||||
"ui.ai_remove": "移除",
|
||||
"ui.ai_request_limit": "每条消息 24 次请求后已停止;再发送一条消息即可继续",
|
||||
"ui.ai_request_timed_out": "模型用时超过 {seconds} 秒。可在“首选项 › 助手”中调高此端点的请求超时。",
|
||||
"ui.ai_request_timeout": "请求超时",
|
||||
"ui.ai_request_timeout_hint": "对此端点的一次模型请求最多可用的时间,30 秒到 1 小时。本地模型较慢时请调高。",
|
||||
"ui.ai_reset": "重置",
|
||||
"ui.ai_settings": "AI 设置…",
|
||||
"ui.ai_show_features": "显示 AI 功能",
|
||||
@@ -2617,6 +2677,7 @@
|
||||
"ui.flip": "翻转",
|
||||
"ui.format": "格式",
|
||||
"ui.frame_rate": "帧率",
|
||||
"ui.frame_rate_details": "帧率详情",
|
||||
"ui.front": "前视",
|
||||
"ui.fully_constrained": "完全约束",
|
||||
"ui.function": "函数",
|
||||
@@ -2957,6 +3018,8 @@
|
||||
"ui.start": "开始",
|
||||
"ui.start_over": "重新开始",
|
||||
"ui.start_page": "起始页",
|
||||
"ui.step_values": "使用方向键和滚轮调整数值",
|
||||
"ui.step_values_tip": "在正在输入的数值框中,上下方向键和框上的鼠标滚轮可将数字增减 1(Shift:10,Alt:0.1)。表达式保持不变。",
|
||||
"ui.stretch": "伸展",
|
||||
"ui.supersampling": "超级采样 (SSAA)",
|
||||
"ui.supersampling_adaptive": "仅在视图静止时",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
Per-machine install with a setup wizard: Welcome, install folder (Program Files\SolidCraft by
|
||||
default, remembered for upgrades), Ready, progress, and a Finish page with a ticked "Launch
|
||||
SolidCraft" box. Installs a Start Menu shortcut, solidcraft-cli, the licence files (including
|
||||
SolidCraft" box. Installs a Start Menu shortcut, solidcraft-cli (and adds its folder to the system PATH), the licence files (including
|
||||
THIRD-PARTY-LICENSES.txt), makes SolidCraft the owner of `.solidcraft` (and of the pre-rename `.solvecraft`), and offers it under
|
||||
"Open with" for STEP, IGES, 3MF and STL. `msiexec /qn` installs silently.
|
||||
Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
@@ -122,6 +122,9 @@
|
||||
|
||||
<Component Id="SolidcraftCli">
|
||||
<File Id="SolidcraftCliExe" Source="$(var.BinDir)\solidcraft-cli.exe" KeyPath="yes" />
|
||||
<!-- The install folder on the system PATH, so MCP clients and shells find `solidcraft-cli` by name;
|
||||
removed again on uninstall. -->
|
||||
<Environment Id="SolidcraftPath" Name="PATH" Value="[INSTALLFOLDER]" Action="set" Part="last" System="yes" Permanent="no" />
|
||||
</Component>
|
||||
<!-- Compatibility shim (docs/compatibility.md): the old CLI name, forwarding to solidcraft-cli. -->
|
||||
<Component Id="SolidcraftCliAlias">
|
||||
|
||||
@@ -482,8 +482,62 @@ struct LOut {
|
||||
@location(1) wp: vec3<f32>,
|
||||
};
|
||||
|
||||
/// A normal from its 12-bit octahedral form (`octahedral6` in view.rs); 0 is unknown (zero).
|
||||
fn octahedral(q: u32) -> vec3<f32> {
|
||||
let qx = q & 63u;
|
||||
let qy = (q >> 6u) & 63u;
|
||||
if (qx == 0u || qy == 0u) { return vec3<f32>(0.0); }
|
||||
var x = f32(qx - 1u) / 31.0 - 1.0;
|
||||
var y = f32(qy - 1u) / 31.0 - 1.0;
|
||||
let z = 1.0 - abs(x) - abs(y);
|
||||
if (z < 0.0) {
|
||||
let ox = x;
|
||||
let oy = y;
|
||||
x = (1.0 - abs(oy)) * select(-1.0, 1.0, ox >= 0.0);
|
||||
y = (1.0 - abs(ox)) * select(-1.0, 1.0, oy >= 0.0);
|
||||
}
|
||||
return normalize(vec3<f32>(x, y, z));
|
||||
}
|
||||
|
||||
/// How steeply the faces beside an edge are seen at `p`: the depth a face gains per pixel across
|
||||
/// the edge, in pixels (tan of the angle between its normal and the view), the larger of the two;
|
||||
/// 1 when neither is known. Capped, so a face seen exactly edge on does not lift the band far.
|
||||
fn grazing(p: vec3<f32>, na: vec3<f32>, nb: vec3<f32>) -> f32 {
|
||||
var toward = normalize(u.back.xyz);
|
||||
if (u.eye.w > 0.5) { toward = normalize(u.eye.xyz - p); }
|
||||
var g = 1.0;
|
||||
for (var i = 0; i < 2; i++) {
|
||||
let n = select(nb, na, i == 0);
|
||||
if (dot(n, n) < 0.25) { continue; }
|
||||
let d = abs(dot(n, toward));
|
||||
g = max(g, sqrt(max(1.0 - d * d, 0.0)) / max(d, 0.02));
|
||||
}
|
||||
return min(g, 50.0);
|
||||
}
|
||||
|
||||
/// Clip z of the world point `p` (clip position `c`) moved `px` pixels toward the eye: the front
|
||||
/// of a thin cylinder around an edge, so the band drawn along it is not buried by a face next to
|
||||
/// the edge seen at a grazing angle. Measured on the screen, it is the same at every zoom.
|
||||
fn lifted_z(p: vec3<f32>, c: vec4<f32>, px: f32, half: vec2<f32>) -> f32 {
|
||||
let b = normalize(u.back.xyz);
|
||||
var a = vec3<f32>(0.0, 0.0, 1.0);
|
||||
if (abs(b.z) > 0.9) { a = vec3<f32>(1.0, 0.0, 0.0); }
|
||||
let side = normalize(cross(b, a));
|
||||
let cs = u.vp * vec4<f32>(p + side, 1.0);
|
||||
if (cs.w <= 1e-6) { return c.z; }
|
||||
// Pixels per millimetre across the view at the point's depth.
|
||||
let ppm = length((cs.xy / cs.w - c.xy / c.w) * half);
|
||||
if (ppm < 1e-9) { return c.z; }
|
||||
var toward = b;
|
||||
if (u.eye.w > 0.5) { toward = normalize(u.eye.xyz - p); }
|
||||
let q = u.vp * vec4<f32>(p + toward * (px / ppm), 1.0);
|
||||
if (q.w <= 1e-6) { return c.z; }
|
||||
// The same depth at the original w, so the band stays where it was on the screen.
|
||||
return q.z / q.w * c.w;
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(1) b: vec3<f32>, @location(2) c: vec4<f32>, @location(3) w: f32) -> LOut {
|
||||
fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(1) b: vec3<f32>, @location(2) c: vec4<f32>, @location(3) wbits: u32) -> LOut {
|
||||
var pa = a;
|
||||
var pb = b;
|
||||
var ca = u.vp * vec4<f32>(a, 1.0);
|
||||
@@ -518,11 +572,22 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(
|
||||
// A line thinner than a pixel is drawn a pixel wide, faded by the width it lacks: a narrower
|
||||
// quad covers no pixel centre when it falls between two rows, so without multisampling thin
|
||||
// lines (the grid's) came and went as the view zoomed (#2823).
|
||||
let wd = max(w, 1.0);
|
||||
// The top bit marks a lifted highlight (`GpuScene::lifted_line`, `lifted_width`); a plain
|
||||
// line's slot is its width as an f32.
|
||||
let lifted = (wbits & 0x80000000u) != 0u;
|
||||
var wa = bitcast<f32>(wbits);
|
||||
if (lifted) { wa = f32((wbits >> 24u) & 127u) / 8.0; }
|
||||
let wd = max(wa, 1.0);
|
||||
let off = nrm * s * wd * 0.5 + dir * (t * 2.0 - 1.0) * wd * 0.5;
|
||||
var z = base.z;
|
||||
if (lifted && base.w > 1e-6) {
|
||||
let p = select(pa, pb, t > 0.5);
|
||||
let g = grazing(p, octahedral(wbits & 4095u), octahedral((wbits >> 12u) & 4095u));
|
||||
z = lifted_z(p, base, wd * 0.5 * g + 1.0, half);
|
||||
}
|
||||
var o: LOut;
|
||||
o.pos = vec4<f32>(base.xy + off / half * base.w, base.z, base.w);
|
||||
o.c = vec4<f32>(c.rgb, c.a * (w / wd));
|
||||
o.pos = vec4<f32>(base.xy + off / half * base.w, z, base.w);
|
||||
o.c = vec4<f32>(c.rgb, c.a * (wa / wd));
|
||||
o.wp = select(pa, pb, t > 0.5);
|
||||
return o;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Pending attribution rows
|
||||
|
||||
A pull request that adds an asset adds **one new file here** instead of a row in `ATTRIBUTION.md`,
|
||||
so two pull requests never edit the same table.
|
||||
|
||||
- Name it after the change, lowercase with dashes: `render-studio-example.md`.
|
||||
- Contents: the rows only, in the format of the files table in `ATTRIBUTION.md`
|
||||
(`| File | Author | Source | Licence |`); a header line is allowed and ignored.
|
||||
- A file that already has a row in `ATTRIBUTION.md` or another pending file fails
|
||||
`cargo xtask lists`. `cargo xtask assets` counts pending rows as attributed.
|
||||
- After a merge, the Generated files workflow runs `cargo xtask fold-pending` on `main`: it appends
|
||||
the rows to `ATTRIBUTION.md` and deletes the files. Never run it in a pull request.
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Presentation/projection state for an exact selected planar source placement.
|
||||
use crate::{DocError, Document, FaceAnchor, ModelBody, ModelState, Result};
|
||||
use crate::{DocError, Document, FaceAnchor, ModelState, Result};
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anchor: &FaceAnchor, author: u64) -> Result<Cow<'a, ModelState>> {
|
||||
@@ -9,17 +9,7 @@ pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anc
|
||||
let mut output = crate::state_in_frame_at(doc, state, author, frame).into_owned();
|
||||
// In place, the body is the model's own, display mesh included: a sketch projecting the
|
||||
// face's edges meshes it once for the features after it too, rather than a copy.
|
||||
let mut body = if crate::is_identity(&relative) {
|
||||
raw.clone()
|
||||
} else {
|
||||
ModelBody::new(raw.name.clone(), solidcraft_kernel::transform_matrix(&raw.body, relative)?, raw.feature)
|
||||
};
|
||||
body.component = raw.component;
|
||||
body.transferred = raw.transferred;
|
||||
body.source_name = raw.source_name.clone();
|
||||
body.source_transform = crate::mat_mul(&relative, &raw.source_transform);
|
||||
body.imported = raw.imported.clone();
|
||||
body.names = crate::naming::NameCell::same(raw);
|
||||
let body = state.body_in_frame(raw, &relative)?;
|
||||
let slot = output.bodies.iter_mut().find(|body| body.name == raw.name).ok_or_else(|| DocError::Unknown("anchored body frame".into()))?;
|
||||
*slot = body;
|
||||
Ok(Cow::Owned(output))
|
||||
|
||||
@@ -220,7 +220,7 @@ impl Document {
|
||||
let (p, look) = (crate::apply_point(&b.source_transform, f.point), f.look.clone());
|
||||
if f.strict_name {
|
||||
let Some(name) = f.name.as_ref() else { continue };
|
||||
let Some(origin_feature) = name.strip_prefix('F').and_then(|name| name.split_once(':')).and_then(|(id, _)| id.parse().ok()) else {
|
||||
let Some(origin_feature) = crate::persistent_name::made_by(name) else {
|
||||
continue;
|
||||
};
|
||||
let target = crate::config::ConfigFaceTarget {
|
||||
|
||||
@@ -146,6 +146,10 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
|
||||
*at = apply_point(m, *at);
|
||||
}
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => map_plane_ref(m, base),
|
||||
PlaneRef::ThroughPoint { base, point } => {
|
||||
map_plane_ref(m, base);
|
||||
map_geo_ref(m, point);
|
||||
}
|
||||
PlaneRef::AtAngle { base, axis_origin, axis_dir, .. } => {
|
||||
map_plane_ref(m, base);
|
||||
*axis_origin = apply_point(m, *axis_origin);
|
||||
@@ -293,8 +297,9 @@ impl Document {
|
||||
/// Body frames with explicit definition names; callers must not infer them from labels.
|
||||
pub fn body_frames_with_source(&self, st: &crate::ModelState) -> Vec<(String, String, u64, Vec<u64>, Mat)> {
|
||||
let mut out = Vec::new();
|
||||
let owners = self.model_body_components(st);
|
||||
for (comp, path, m) in self.placements() {
|
||||
for b in st.bodies.iter().filter(|b| self.model_body_component(b) == comp) {
|
||||
for (b, _) in st.bodies.iter().zip(&owners).filter(|(_, owner)| **owner == comp) {
|
||||
let name = self.world_body_name(st, &b.name, comp, &path);
|
||||
let w = match self.model_body_offset(b) {
|
||||
Some(o) => mat_mul(&m, o),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::persistent_name::{Base, FaceName, FeatureId};
|
||||
use crate::{FeatureKind, PlaneRef};
|
||||
|
||||
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
|
||||
@@ -22,6 +23,14 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
|
||||
{
|
||||
return format!("{new}:face:{suffix}");
|
||||
}
|
||||
// A name that reads is remapped by its structure: every feature it names, and a body
|
||||
// face's body. Text of no known form is remapped token by token below.
|
||||
if let Ok(face) = FaceName::parse(name)
|
||||
&& !matches!(face.base, Base::Other(_))
|
||||
&& let Some(mapped) = face.map(&mut |id, _| Some(ids.get(&id.0).map_or(id, |n| FeatureId(*n))), &mut |b| bodies.get(b).cloned())
|
||||
{
|
||||
return mapped.to_string();
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut at = 0;
|
||||
while at < name.len() {
|
||||
@@ -106,6 +115,10 @@ fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<Str
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1)?,
|
||||
PlaneRef::ThroughPoint { base, point } => {
|
||||
remap_plane(base, ids, bodies, depth + 1)?;
|
||||
remap_geo(point, ids, bodies, depth + 1)?;
|
||||
}
|
||||
PlaneRef::Face { name, anchor, .. } => {
|
||||
if let Some(name) = name {
|
||||
*name = remap_topology(name, ids, bodies);
|
||||
@@ -285,6 +298,10 @@ fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> {
|
||||
checked_geo(plane, depth + 1)?;
|
||||
checked_geo(line, depth + 1)?;
|
||||
}
|
||||
PlaneRef::ThroughPoint { base, point } => {
|
||||
checked_plane(base, depth + 1)?;
|
||||
checked_geo(point, depth + 1)?;
|
||||
}
|
||||
PlaneRef::ThreePoints { a, b, c } => {
|
||||
checked_geo(a, depth + 1)?;
|
||||
checked_geo(b, depth + 1)?;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FeatureKind;
|
||||
|
||||
fn kind(v: Value) -> FeatureKind {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_coil_saves_unchanged() {
|
||||
let old = json!({
|
||||
"type": "coil",
|
||||
"base": [0.0, 0.0, 0.0],
|
||||
"axis": [0.0, 0.0, 1.0],
|
||||
"diameter": "40",
|
||||
"pitch": "8",
|
||||
"turns": "4",
|
||||
"section_size": "3",
|
||||
"section": "circular",
|
||||
"operation": "new_body"
|
||||
});
|
||||
assert_eq!(serde_json::to_value(kind(old.clone())).unwrap(), old);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_coil_from_a_sketch_saves_its_sketch_and_profiles() {
|
||||
let v = json!({
|
||||
"type": "coil",
|
||||
"base": [0.0, 0.0, 0.0],
|
||||
"axis": [0.0, 0.0, 1.0],
|
||||
"sketch": 7,
|
||||
"profiles": {"type": "indices", "indices": [0]},
|
||||
"pitch": "8",
|
||||
"turns": "4",
|
||||
"section": "circular",
|
||||
"operation": "new_body"
|
||||
});
|
||||
assert_eq!(serde_json::to_value(kind(v.clone())).unwrap(), v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_profiles_are_omitted() {
|
||||
let v = json!({
|
||||
"type": "coil",
|
||||
"base": [0.0, 0.0, 0.0],
|
||||
"axis": [0.0, 0.0, 1.0],
|
||||
"sketch": 7,
|
||||
"pitch": "8",
|
||||
"turns": "4",
|
||||
"section": "circular",
|
||||
"operation": "new_body"
|
||||
});
|
||||
let saved = serde_json::to_value(kind(v.clone())).unwrap();
|
||||
assert_eq!(saved, v);
|
||||
assert!(saved.get("profiles").is_none());
|
||||
}
|
||||
@@ -526,6 +526,89 @@ impl ExactSketchCurves<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl ExactSketchCurves<'_> {
|
||||
/// The roles of a thin wall's side that runs parallel to some of the wall's `curves`, off
|
||||
/// them by one of the wall's `distances`: `wall:<curve>:left` (or `:right`) for the side of
|
||||
/// that curve, left or right of the direction it is drawn in. A thin wall's sides lie off the
|
||||
/// sketch (by half its thickness, say), so [`find_all_along`](Self::find_all_along) finds no
|
||||
/// curve under them. Every curve the side runs along and overlaps names it (the first its
|
||||
/// name, the others aliases), in the sketch's order; empty when it is parallel to none, or
|
||||
/// its distance is not one of the wall's.
|
||||
pub(crate) fn find_offset(&self, generated: &Seg2, curves: &[String], distances: [f64; 2]) -> Vec<String> {
|
||||
let wanted = |offset: f64, scale: f64| {
|
||||
let tolerance = 1e-7 * scale.max(1.);
|
||||
offset.abs() > tolerance && distances.iter().any(|d| (d - offset.abs()).abs() <= tolerance)
|
||||
};
|
||||
let side = |offset: f64| if offset > 0. { "left" } else { "right" };
|
||||
let tau = std::f64::consts::TAU;
|
||||
let mut roles = Vec::new();
|
||||
for (id, _, originals) in &self.segments {
|
||||
if !curves.iter().any(|c| c == id) {
|
||||
continue;
|
||||
}
|
||||
let found = originals.iter().find_map(|original| match (*original, *generated) {
|
||||
(Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => {
|
||||
let direction = b - a;
|
||||
let length = direction.len();
|
||||
let scale = length.max((d - c).len());
|
||||
if length <= 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let (left_c, left_d) = (direction.cross(c - a) / length, direction.cross(d - a) / length);
|
||||
if (left_c - left_d).abs() > 1e-9 * scale.max(1.) || !wanted(left_c, scale) {
|
||||
return None;
|
||||
}
|
||||
let (tc, td) = ((c - a).dot(direction) / direction.len2(), (d - a).dot(direction) / direction.len2());
|
||||
(tc.min(td).max(0.) < tc.max(td).min(1.) - 1e-9).then(|| side(left_c))
|
||||
}
|
||||
(Seg2::Arc { center: oc, radius: or, start: os, sweep: ow }, Seg2::Arc { center: gc, radius: gr, start: gs, sweep: gw }) => {
|
||||
let scale = or.max(gr);
|
||||
if oc.dist(gc) > 1e-9 * scale.max(1.) || !wanted(gr - or, scale) || ow == 0. || gw == 0. {
|
||||
return None;
|
||||
}
|
||||
// Drawn counter-clockwise, the curve's left is towards its centre.
|
||||
let left = if ow > 0. { gr < or } else { gr > or };
|
||||
let inside = |start: f64, sweep: f64, angle: f64| {
|
||||
let low = if sweep >= 0. { start } else { start + sweep };
|
||||
(angle - low).rem_euclid(tau) <= sweep.abs() + 1e-9
|
||||
};
|
||||
let overlaps = inside(os, ow, gs)
|
||||
|| inside(os, ow, gs + gw)
|
||||
|| inside(os, ow, gs + gw / 2.)
|
||||
|| inside(gs, gw, os)
|
||||
|| inside(gs, gw, os + ow / 2.)
|
||||
|| ow.abs() >= tau - 1e-9
|
||||
|| gw.abs() >= tau - 1e-9;
|
||||
overlaps.then_some(if left { "left" } else { "right" })
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
// The end of an open chain: a straight cut across the curve, through its end point.
|
||||
let cap = || {
|
||||
let Seg2::Line { a: c, b: d } = *generated else { return None };
|
||||
let width = (d - c).len();
|
||||
if width <= 1e-12 || (width - (distances[0] + distances[1])).abs() > 1e-7 * width.max(1.) {
|
||||
return None;
|
||||
}
|
||||
originals.iter().find_map(|original| {
|
||||
[("start", original.start(), original.start_tangent()), ("end", original.end(), original.end_tangent())].into_iter().find_map(
|
||||
|(end, point, tangent)| {
|
||||
let across = (d - c) * (1. / width);
|
||||
let on_line = across.cross(point - c).abs() <= 1e-7 * width.max(1.);
|
||||
let along = (point - c).dot(across);
|
||||
(tangent.dot(across).abs() <= 1e-7 && on_line && (-1e-7..=width + 1e-7).contains(&along)).then_some(end)
|
||||
},
|
||||
)
|
||||
})
|
||||
};
|
||||
if let Some(side) = found.or_else(cap) {
|
||||
roles.push(format!("wall:{id}:{side}"));
|
||||
}
|
||||
}
|
||||
roles
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool {
|
||||
match (original, generated) {
|
||||
(Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => {
|
||||
@@ -580,18 +663,14 @@ impl Document {
|
||||
| Some(solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), .. })
|
||||
| Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), .. })
|
||||
| Some(solidcraft_kernel::FaceSource::CylinderCap { producer, .. }) => *producer,
|
||||
_ => name
|
||||
.strip_prefix('F')
|
||||
.and_then(|name| name.split_once(':'))
|
||||
.and_then(|(id, _)| id.parse().ok())
|
||||
.ok_or_else(|| DocError::Invalid("face source producer missing".into()))?,
|
||||
_ => crate::persistent_name::made_by(&name).ok_or_else(|| DocError::Invalid("face source producer missing".into()))?,
|
||||
};
|
||||
let certificate = self.configuration_face_certificate(body, index)?;
|
||||
if let Some(FaceCertificate::ExtrudeSide { curve, .. }) = &certificate {
|
||||
name = format!("F{origin}:side:{curve}");
|
||||
} else if let Some(FaceCertificate::ShellPlanar { source, inner, .. }) = &certificate {
|
||||
name = format!("F{origin}:{}", shell_certificate_role(source, *inner)?);
|
||||
} else if !name.starts_with(&format!("F{origin}:")) {
|
||||
} else if crate::persistent_name::made_by(&name) != Some(origin) {
|
||||
name = format!("F{origin}:face:{index}");
|
||||
}
|
||||
let inverse = crate::mat_inverse(&body.source_transform).ok_or_else(|| DocError::Invalid("invalid definition placement".into()))?;
|
||||
@@ -725,7 +804,7 @@ impl Document {
|
||||
}
|
||||
let full = names.faces.get(index)?;
|
||||
let stem = crate::naming::strip_piece(full);
|
||||
let origin = stem.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
|
||||
let origin = crate::persistent_name::made_by(stem)?;
|
||||
let inverse = crate::mat_inverse(&body.source_transform)?;
|
||||
let mut candidates = vec![stem];
|
||||
if full != stem {
|
||||
@@ -819,10 +898,13 @@ impl Document {
|
||||
return Ok(indices);
|
||||
}
|
||||
let names = crate::naming::names(body);
|
||||
let own = format!("F{origin}:");
|
||||
let stem = |index: usize| names.faces.get(index).map(|name| crate::naming::strip_piece(name));
|
||||
let seed: Vec<usize> =
|
||||
indices.into_iter().filter(|index| stem(*index).is_some_and(|name| name.starts_with(&own) && !name.contains('@'))).collect();
|
||||
let seed: Vec<usize> = indices
|
||||
.into_iter()
|
||||
.filter(|index| {
|
||||
stem(*index).is_some_and(|name| crate::persistent_name::made_by(name) == Some(origin) && !crate::persistent_name::is_copy(name))
|
||||
})
|
||||
.collect();
|
||||
let feature = self.feature(origin).map_or_else(|| format!("F{origin}"), |feature| feature.name.clone());
|
||||
let ambiguous = || DocError::Invalid(format!("the attached flat face of {feature} on `{}` is missing or ambiguous", body.name));
|
||||
let first = seed.first().and_then(|index| stem(*index)).ok_or_else(ambiguous)?;
|
||||
@@ -841,7 +923,7 @@ impl Document {
|
||||
return indices;
|
||||
}
|
||||
let names = crate::naming::names(body);
|
||||
let copy = |index: &usize| names.faces.get(*index).is_some_and(|name| crate::naming::strip_piece(name).contains('@'));
|
||||
let copy = |index: &usize| names.faces.get(*index).is_some_and(|name| crate::persistent_name::is_copy(name));
|
||||
if indices.iter().all(copy) {
|
||||
return indices;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Configuration graph remapping for independent component copies.
|
||||
use super::{Column, ConfigTable};
|
||||
use crate::{DocError, Result};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ConfigCloneMaps {
|
||||
@@ -69,11 +69,7 @@ impl ConfigTable {
|
||||
maps.bodies.get(&target.body).zip(maps.features.get(&target.origin_feature)).map(|(body, origin)| {
|
||||
let mut target = target.clone();
|
||||
target.body = body.clone();
|
||||
target.name = target
|
||||
.name
|
||||
.strip_prefix(&format!("F{}:", target.origin_feature))
|
||||
.map(|role| format!("F{origin}:{role}"))
|
||||
.unwrap_or_else(|| target.name.clone());
|
||||
target.name = crate::persistent_name::map_features(&target.name, &maps.features).unwrap_or_else(|| target.name.clone());
|
||||
target.origin_feature = *origin;
|
||||
if let Some(certificate) = &mut target.certificate {
|
||||
certificate.remap_features(&maps.features);
|
||||
@@ -106,6 +102,9 @@ impl ConfigTable {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The ids taken so far, looked up rather than scanned: copies of rules whose ids share
|
||||
// their first 40 characters try one id after another.
|
||||
let mut taken: BTreeSet<String> = next.rules.iter().map(|rule| rule.id.clone()).collect();
|
||||
for rule in &self.rules {
|
||||
if let Some(mut copied) = rule.clone_for_targets(maps) {
|
||||
if next.rules.len() >= super::MAX_RULES {
|
||||
@@ -114,8 +113,9 @@ impl ConfigTable {
|
||||
let stem: String = rule.id.chars().take(40).collect();
|
||||
copied.id = (1..=super::MAX_RULES + 1)
|
||||
.map(|i| format!("{stem}_copy{i}"))
|
||||
.find(|id| !next.rules.iter().any(|rule| rule.id == *id))
|
||||
.find(|id| !taken.contains(id))
|
||||
.ok_or_else(|| DocError::Invalid("no configuration rule ID available".into()))?;
|
||||
taken.insert(copied.id.clone());
|
||||
copied.name = format!("{} (copy)", rule.name.chars().take(120).collect::<String>());
|
||||
next.rules.push(copied);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub const MAX_RULES: usize = 128;
|
||||
/// Most configuration rules a design keeps. A rule drives one feature's suppression, so an
|
||||
/// assembly that switches a few hundred parts on and off, by two rules each, needs hundreds.
|
||||
pub const MAX_RULES: usize = 2048;
|
||||
const MAX_NODES: usize = 64;
|
||||
const MAX_DEPTH: usize = 8;
|
||||
|
||||
@@ -235,7 +237,61 @@ fn condition(doc: &Document, values: &BTreeMap<String, expr::Value>, value: &Rul
|
||||
}
|
||||
}
|
||||
|
||||
/// The features whose suppression a condition reads.
|
||||
fn suppression_reads(c: &RuleCondition) -> Vec<u64> {
|
||||
match c {
|
||||
RuleCondition::Always | RuleCondition::Parameter { .. } => Vec::new(),
|
||||
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => conditions.iter().flat_map(suppression_reads).collect(),
|
||||
RuleCondition::Not { condition } => suppression_reads(condition),
|
||||
RuleCondition::Suppression { feature, .. } => vec![*feature],
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// After a plain parameter edit (`before`: the parameter values before it): the enabled rules
|
||||
/// that switch a feature's suppression and whose condition reads a parameter whose value
|
||||
/// changed, or the suppression of a feature switched earlier in this pass, applied in their
|
||||
/// order as activation applies them. A switch parameter alone so switches its features.
|
||||
/// Driven parameters and restrictions still wait for a configuration edit or activation, and
|
||||
/// a rule whose condition reads nothing that changed leaves its feature as it is. A rule that
|
||||
/// cannot be evaluated (a missing parameter) is skipped: it never blocks the edit.
|
||||
/// Returns the features switched, with their new suppression.
|
||||
pub fn apply_suppression_rules(&mut self, before: &BTreeMap<String, expr::Value>) -> Vec<(u64, bool)> {
|
||||
if !self.configs.rules.iter().any(|r| r.enabled && matches!(r.effect, RuleEffect::DriveSuppression { .. })) {
|
||||
return Vec::new();
|
||||
}
|
||||
let (values, _) = self.param_values();
|
||||
let changed: BTreeSet<&String> = values
|
||||
.iter()
|
||||
.filter(|(name, v)| before.get(*name).is_none_or(|b| !close(b.v, v.v) || b.len != v.len || b.ang != v.ang))
|
||||
.map(|(name, _)| name)
|
||||
.chain(before.keys().filter(|name| !values.contains_key(*name)))
|
||||
.collect();
|
||||
if changed.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut switched: Vec<(u64, bool)> = Vec::new();
|
||||
for rule in self.configs.rules.clone() {
|
||||
let RuleEffect::DriveSuppression { feature, value } = rule.effect else { continue };
|
||||
if !rule.enabled {
|
||||
continue;
|
||||
}
|
||||
let reads = rule.parameter_references().iter().any(|n| changed.contains(n))
|
||||
|| suppression_reads(&rule.condition).iter().any(|f| switched.iter().any(|(s, _)| s == f));
|
||||
if !reads || !condition(self, &values, &rule.condition, rule.length_unit.as_deref()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = self.feature_mut(feature)
|
||||
&& f.suppressed != value
|
||||
{
|
||||
f.suppressed = value;
|
||||
switched.retain(|(s, _)| *s != feature);
|
||||
switched.push((feature, value));
|
||||
}
|
||||
}
|
||||
switched
|
||||
}
|
||||
|
||||
/// Apply ordered drives, then enforce all matched restrictions on the final state.
|
||||
/// Running on a scratch document makes a failing rule fully atomic for callers.
|
||||
pub(super) fn evaluate_configuration_rules(&mut self) -> Result<Vec<RuleTrace>> {
|
||||
@@ -572,7 +628,16 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[valid.clone()]}})).is_ok());
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":null}})).is_err());
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":vec![valid.clone();129]}})).is_err());
|
||||
// An assembly that switches a few hundred parts on and off, by two rules each, keeps them.
|
||||
let many: Vec<Value> = (0..600)
|
||||
.map(|i| {
|
||||
let mut r = valid.clone();
|
||||
r["id"] = serde_json::json!(format!("r{i}"));
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":many}})).is_ok());
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":vec![valid.clone();MAX_RULES + 1]}})).is_err());
|
||||
let mut condition = serde_json::json!({"type":"always"});
|
||||
for _ in 0..8 {
|
||||
condition = serde_json::json!({"type":"not","condition":condition});
|
||||
@@ -636,7 +701,7 @@ mod tests {
|
||||
Value::Null,
|
||||
serde_json::json!([deep]),
|
||||
serde_json::json!([wide]),
|
||||
serde_json::json!(vec![valid; 129]),
|
||||
serde_json::json!(vec![valid; MAX_RULES + 1]),
|
||||
serde_json::json!([invalid_context]),
|
||||
] {
|
||||
let mut saved = serde_json::to_value(design()).unwrap();
|
||||
|
||||
@@ -122,12 +122,13 @@ impl Document {
|
||||
/// Certify named generator lineage; generic index-derived naming is unsupported.
|
||||
pub fn certify_config_face(&self, target: &ConfigFaceTarget) -> Result<()> {
|
||||
target.validate()?;
|
||||
let prefix = format!("F{}:", target.origin_feature);
|
||||
let role = target.name.strip_prefix(&prefix).ok_or_else(|| DocError::Invalid("face lineage does not match its feature".into()))?;
|
||||
let role = crate::persistent_name::split_feature(&target.name)
|
||||
.filter(|(id, _)| *id == target.origin_feature)
|
||||
.map(|(_, role)| role)
|
||||
.ok_or_else(|| DocError::Invalid("face lineage does not match its feature".into()))?;
|
||||
// An exact history name may name a piece by the faces around it (`F2:top#[F2:side:l4,…]`),
|
||||
// never by a number (an older design's piece, or a tie the faces around it left).
|
||||
let numbered =
|
||||
target.name.match_indices('#').any(|(i, _)| target.name.get(i + 1..).is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())));
|
||||
let numbered = crate::persistent_name::is_numbered_piece(&target.name);
|
||||
let lineage_piece = matches!(target.certificate, Some(super::FaceCertificate::Lineage)) && !numbered;
|
||||
if crate::naming::strip_piece(&target.name) != target.name && !lineage_piece {
|
||||
return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into()));
|
||||
@@ -316,7 +317,7 @@ impl Document {
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if target.name.ends_with(":side") {
|
||||
if crate::persistent_name::is_wall(&target.name) {
|
||||
if indices.len() != 1 {
|
||||
return Err(DocError::Invalid("split curved face lineage is not yet certified".into()));
|
||||
}
|
||||
|
||||
@@ -99,6 +99,18 @@ pub enum ConstructGeom {
|
||||
Point(Vec3),
|
||||
}
|
||||
|
||||
impl ConstructGeom {
|
||||
/// The geometry moved by `m` (where its component is placed).
|
||||
pub fn placed(&self, m: &crate::Mat) -> ConstructGeom {
|
||||
let (point, vector) = (|p: Vec3| crate::apply_point(m, p), |v: Vec3| crate::apply_vector(m, v));
|
||||
match self {
|
||||
ConstructGeom::Plane(p) => Plane::new(point(p.origin), vector(p.x), vector(p.y)).map_or_else(|| self.clone(), ConstructGeom::Plane),
|
||||
ConstructGeom::Axis { origin, dir } => ConstructGeom::Axis { origin: point(*origin), dir: vector(*dir) },
|
||||
ConstructGeom::Point(p) => ConstructGeom::Point(point(*p)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Construct {
|
||||
pub feature: u64,
|
||||
@@ -580,6 +592,11 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
|
||||
let (p, tan) = along(&path_points(ctx, path)?, t).ok_or_else(|| bad("the path has no length"))?;
|
||||
Plane::from_normal(p, tan).ok_or_else(degenerate)
|
||||
}
|
||||
PlaneRef::ThroughPoint { base, point: at } => {
|
||||
let b = ctx.doc.resolve_plane_in(ctx.vals, base, ctx.depth + 1, Some(ctx.st))?;
|
||||
let n = b.normal().normalized().ok_or_else(degenerate)?;
|
||||
Ok(Plane { origin: b.origin + n * (point(ctx, at)? - b.origin).dot(n), ..b })
|
||||
}
|
||||
_ => ctx.doc.resolve_plane_in(ctx.vals, p, ctx.depth + 1, Some(ctx.st)),
|
||||
}
|
||||
}
|
||||
@@ -688,6 +705,11 @@ impl PlaneRef {
|
||||
PlaneRef::ThreePoints { a, b, c } => vec![a.as_mut(), b.as_mut(), c.as_mut()],
|
||||
PlaneRef::Tangent { face, .. } => vec![face.as_mut()],
|
||||
PlaneRef::AlongPath { path, .. } => vec![path.as_mut()],
|
||||
PlaneRef::ThroughPoint { base, point } => {
|
||||
let mut refs = base.refs_mut();
|
||||
refs.push(point.as_mut());
|
||||
refs
|
||||
}
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => base.refs_mut(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
|
||||
+106
-18
@@ -54,6 +54,9 @@ pub enum PlaneRef {
|
||||
},
|
||||
/// Another plane offset along its normal by an expression.
|
||||
Offset { base: Box<PlaneRef>, distance: Expr },
|
||||
/// Another plane moved along its normal until it goes through a point (a vertex, sketch
|
||||
/// point or construction point), so it follows the point.
|
||||
ThroughPoint { base: Box<PlaneRef>, point: Box<GeoRef> },
|
||||
/// Another plane rotated about a line (in world coordinates) by an angle expression.
|
||||
AtAngle { base: Box<PlaneRef>, axis_origin: Vec3, axis_dir: Vec3, angle: Expr },
|
||||
/// A construction plane feature (by name).
|
||||
@@ -721,6 +724,11 @@ pub enum FeatureKind {
|
||||
/// A cosmetic thread in the holes (e.g. "M6x1").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
thread: Option<String>,
|
||||
/// Threaded depth from the cylindrical wall's entry; `None` = its full useful length.
|
||||
/// Independent of the drill depth and excluding the drill point and entry recess.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(length = "Thread depth")]
|
||||
thread_depth: Option<Expr>,
|
||||
/// To Object: drill down to the face (or point) here instead of `depth`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
to: Option<FacePick>,
|
||||
@@ -1005,14 +1013,23 @@ pub enum FeatureKind {
|
||||
base: PointExpr,
|
||||
#[serde(default = "z_axis")]
|
||||
axis: Dir,
|
||||
/// The sketch whose plane and profile the coil sweeps. Its absence means the primitive
|
||||
/// circular or square section below.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
sketch: Option<SketchId>,
|
||||
#[serde(default = "all_profiles", skip_serializing_if = "profiles_are_all")]
|
||||
#[walk(profiles_of = sketch)]
|
||||
profiles: ProfileSel,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(length = "Diameter")]
|
||||
diameter: Expr,
|
||||
diameter: Option<Expr>,
|
||||
#[walk(length = "Pitch")]
|
||||
pitch: Expr,
|
||||
#[walk(unitless = "Revolutions")]
|
||||
turns: Expr,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(length = "Section size")]
|
||||
section_size: Expr,
|
||||
section_size: Option<Expr>,
|
||||
#[serde(default)]
|
||||
section: CoilSection,
|
||||
/// -1 inside, 0 on the centre, 1 outside of the diameter.
|
||||
@@ -1140,6 +1157,19 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<BodyName>,
|
||||
},
|
||||
/// Path flange: a chain of edge flanges on the sheet edge at `edge` that follows an open
|
||||
/// chain of sketch lines drawn across that edge: each line one flange as long as the line,
|
||||
/// each corner one bend.
|
||||
SheetPath {
|
||||
edge: Position,
|
||||
sketch: SketchId,
|
||||
curves: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(length = "Bend radius")]
|
||||
radius: Option<Expr>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<BodyName>,
|
||||
},
|
||||
/// Unfold a sheet body flat (all bends), or refold it.
|
||||
SheetUnfold {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -2015,12 +2045,20 @@ pub enum HoleKind {
|
||||
cb_diameter: Expr,
|
||||
#[walk(length = "Counterbore depth")]
|
||||
cb_depth: Expr,
|
||||
/// Optional drill point below the narrow cylindrical bore; absent stays flat.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(angle = "Tip angle")]
|
||||
tip_angle: Option<Expr>,
|
||||
},
|
||||
Countersink {
|
||||
#[walk(length = "Countersink diameter")]
|
||||
cs_diameter: Expr,
|
||||
#[walk(angle = "Countersink angle")]
|
||||
cs_angle: Expr,
|
||||
/// Optional drill point, independent of the entry countersink.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(angle = "Tip angle")]
|
||||
tip_angle: Option<Expr>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2177,6 +2215,7 @@ impl FeatureKind {
|
||||
| FeatureKind::SheetHem { .. }
|
||||
| FeatureKind::SheetPerforate { .. }
|
||||
| FeatureKind::SheetFold { .. }
|
||||
| FeatureKind::SheetPath { .. }
|
||||
| FeatureKind::SheetUnfold { .. }
|
||||
| FeatureKind::SheetConvert { .. }
|
||||
)
|
||||
@@ -2232,6 +2271,7 @@ impl FeatureKind {
|
||||
FeatureKind::SheetHem { .. } => "HemFeature",
|
||||
FeatureKind::SheetPerforate { .. } => "PerforateFeature",
|
||||
FeatureKind::SheetFold { .. } => "FoldFeature",
|
||||
FeatureKind::SheetPath { .. } => "PathFlangeFeature",
|
||||
FeatureKind::SheetUnfold { refold: false, .. } => "UnfoldFeature",
|
||||
FeatureKind::SheetUnfold { .. } => "RefoldFeature",
|
||||
FeatureKind::SheetConvert { .. } => "ConvertToSheetMetalFeature",
|
||||
@@ -2313,6 +2353,7 @@ impl FeatureKind {
|
||||
FeatureKind::SheetHem { .. } => "Hem",
|
||||
FeatureKind::SheetPerforate { .. } => "Perforate",
|
||||
FeatureKind::SheetFold { .. } => "Fold",
|
||||
FeatureKind::SheetPath { .. } => "PathFlange",
|
||||
FeatureKind::SheetUnfold { refold: false, .. } => "Unfold",
|
||||
FeatureKind::SheetUnfold { .. } => "Refold",
|
||||
FeatureKind::SheetConvert { .. } => "ConvertToSheetMetal",
|
||||
@@ -2412,6 +2453,10 @@ pub(crate) fn all_profiles() -> ProfileSel {
|
||||
ProfileSel::All
|
||||
}
|
||||
|
||||
fn profiles_are_all(p: &ProfileSel) -> bool {
|
||||
matches!(p, ProfileSel::All)
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -2543,14 +2588,15 @@ pub struct Document {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub named_views: Vec<NamedView>,
|
||||
/// What was hidden when the design was saved. A session takes it from here when the design
|
||||
/// opens and keeps the live state itself (`browser.visibility`: saved, not an undo step), so
|
||||
/// a document in a session leaves this empty; the session puts it back when it saves.
|
||||
/// opens and keeps the live state itself (`browser.visibility`: saved, and undone like any
|
||||
/// other change), so a document in a session leaves this empty; the session puts it back when
|
||||
/// it saves.
|
||||
#[serde(default, skip_serializing_if = "Visibility::is_empty")]
|
||||
pub visibility: Visibility,
|
||||
}
|
||||
|
||||
/// The browser's view of a design: items and folders hidden or shown, sketch profiles and
|
||||
/// dimensions shown, bodies locked. Saved with the design, but not an undo step.
|
||||
/// dimensions shown, bodies locked. Saved with the design and an undo step of its own.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Visibility {
|
||||
@@ -3011,6 +3057,24 @@ impl Document {
|
||||
if body.transferred || body.source_name.is_some() { body.component } else { self.legacy_body_component(&body.name, body.feature) }
|
||||
}
|
||||
|
||||
/// [`Document::model_body_component`] of every body of `state`, in its order, with the
|
||||
/// timeline looked up once: placing a large assembly asked it of every body for every
|
||||
/// placement, each time searching the features.
|
||||
pub fn model_body_components(&self, state: &crate::ModelState) -> Vec<u64> {
|
||||
let features: std::collections::HashMap<u64, u64> = self.features.iter().rev().map(|f| (f.id, f.component)).collect();
|
||||
state
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
if b.transferred || b.source_name.is_some() {
|
||||
b.component
|
||||
} else {
|
||||
self.body_components.get(&b.name).copied().unwrap_or_else(|| features.get(&b.feature).copied().unwrap_or(0))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The components that own a body in `state` ([`Document::model_body_component`] of each),
|
||||
/// in one pass: the timeline is looked up once, not once per body.
|
||||
pub fn body_owners(&self, state: &crate::ModelState) -> std::collections::HashSet<u64> {
|
||||
@@ -3108,14 +3172,11 @@ impl Document {
|
||||
}
|
||||
|
||||
fn unique_name(&self, base: &str) -> String {
|
||||
let mut n = 1;
|
||||
loop {
|
||||
let name = format!("{base}{n}");
|
||||
if !self.features.iter().any(|f| f.name == name) {
|
||||
return name;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
// The numbers taken after `base`, gathered once: the thousandth cylinder of a design
|
||||
// tried Cylinder1, Cylinder2 … each against every feature's name.
|
||||
let taken = crate::params::numbers_after(base, self.features.iter().map(|f| f.name.as_str()));
|
||||
let n = (1..).find(|n| !taken.contains(n)).unwrap_or(1);
|
||||
format!("{base}{n}")
|
||||
}
|
||||
|
||||
/// The deepest timeline folder spanning the insertion slot between `marker - 1` and
|
||||
@@ -3330,9 +3391,9 @@ impl Document {
|
||||
_ => true,
|
||||
})
|
||||
.collect();
|
||||
self.appearances
|
||||
.faces
|
||||
.retain(|face| !face.strict_name || !face.name.as_ref().is_some_and(|name| gone.iter().any(|id| name.starts_with(&format!("F{id}:")))));
|
||||
self.appearances.faces.retain(|face| {
|
||||
!face.strict_name || !face.name.as_ref().and_then(|name| crate::persistent_name::made_by(name)).is_some_and(|made| gone.contains(&made))
|
||||
});
|
||||
if keep.contains(&false) {
|
||||
let mut k = keep.iter();
|
||||
self.configs.columns.retain(|_| k.next().copied().unwrap_or(true));
|
||||
@@ -3548,7 +3609,8 @@ impl Document {
|
||||
| PlaneRef::ThreePoints { .. }
|
||||
| PlaneRef::Tangent { .. }
|
||||
| PlaneRef::AlongPath { .. }
|
||||
| PlaneRef::Perpendicular { .. } => {
|
||||
| PlaneRef::Perpendicular { .. }
|
||||
| PlaneRef::ThroughPoint { .. } => {
|
||||
let st = st.ok_or_else(|| DocError::Invalid("this plane is built from the model: evaluate first".into()))?;
|
||||
crate::construct::resolve_geo_plane(&crate::construct::Ctx { doc: self, vals, st, depth }, p)
|
||||
}
|
||||
@@ -3670,7 +3732,9 @@ impl Document {
|
||||
/// `moved_from`: the stored sketch, when `sk` is that sketch with its links refreshed. A
|
||||
/// link's points are where their source is now; the sides and turns the solve keeps are
|
||||
/// those of the stored state ([`Sketch::positions_in`]), not the ones read beside a line
|
||||
/// that has moved past a point.
|
||||
/// that has moved past a point. Where the links moved so far that the solve fails or turns
|
||||
/// part of the sketch over, they are walked there in steps
|
||||
/// ([`solidcraft_sketch::solve_after_link_move`]).
|
||||
pub fn solve_sketch(
|
||||
&self,
|
||||
vals: &std::collections::BTreeMap<String, expr::Value>,
|
||||
@@ -3699,7 +3763,31 @@ impl Document {
|
||||
// Offsets always require another solve after refresh. Only that last report is
|
||||
// published; the first report only decides which state the second solve starts from.
|
||||
let first_analysis = if offsets { solidcraft_sketch::Analysis::StatusOnly } else { analysis };
|
||||
// A linked or fixed point is not where the stored sketch has it.
|
||||
let given_moved = moved_from
|
||||
.is_some_and(|from| sk.points.iter().zip(&from.points).any(|(p, q)| (p.fixed || p.link.is_some()) && p.id == q.id && p.pos != q.pos));
|
||||
let mut report = solidcraft_sketch::solve_after_edit_with_analysis(sk, stored, was(sk).as_deref(), first_analysis);
|
||||
// Links that moved far (a large change of a size upstream): when the jump fails, or
|
||||
// lands on the mirror image of a side or turn the stored sketch has, walked there in
|
||||
// steps; a turn the walk cannot avoid either is reported as one.
|
||||
if let Some(from) = moved_from.filter(|_| given_moved) {
|
||||
let flips = if report.ok() { solidcraft_sketch::flips_from_stored(sk, from) } else { Vec::new() };
|
||||
if !report.keeps_orientation() || !flips.is_empty() {
|
||||
match solidcraft_sketch::solve_after_link_move(sk, from, stored, first_analysis) {
|
||||
Some(walked) => report = walked,
|
||||
None => {
|
||||
for id in flips {
|
||||
if !report.failing.contains(&id) {
|
||||
report.failing.push(id.clone());
|
||||
}
|
||||
if !report.folded.contains(&id) {
|
||||
report.folded.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if offsets {
|
||||
// Source dimensions may have moved curves during the solve.
|
||||
sk.refresh_offsets()?;
|
||||
|
||||
+772
-226
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
//! fingerprint it, so each sketch command took longer the more parts were inserted.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use solidcraft_geom::{Plane, Vec2, Vec3};
|
||||
@@ -13,16 +14,18 @@ use solidcraft_sketch::Sketch;
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Work counted on this thread: bodies moved, STEP texts digested, joint faces measured.
|
||||
/// Work counted on this thread: bodies moved, STEP texts digested, joint faces and bodies
|
||||
/// measured.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(super) struct Counts {
|
||||
pub moved: usize,
|
||||
pub digested: usize,
|
||||
pub joint_faces: usize,
|
||||
pub measures: usize,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static COUNTS: Cell<Counts> = const { Cell::new(Counts { moved: 0, digested: 0, joint_faces: 0 }) };
|
||||
static COUNTS: Cell<Counts> = const { Cell::new(Counts { moved: 0, digested: 0, joint_faces: 0, measures: 0 }) };
|
||||
}
|
||||
|
||||
pub(super) fn count(f: impl FnOnce(&mut Counts)) {
|
||||
@@ -223,6 +226,72 @@ fn a_sketch_on_a_face_still_sees_the_placed_bodies() {
|
||||
assert_eq!(work.digested, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_sketch_on_a_turned_occurrence_reuses_its_frame_bodies() {
|
||||
use solidcraft_sketch::{LinkKind, LinkSource};
|
||||
|
||||
let mut doc = Document::new("turned face");
|
||||
let comp = doc.add_component(Some("Plate"), 0).unwrap();
|
||||
let occurrence = doc.occurrences.iter_mut().find(|o| o.component == comp).unwrap();
|
||||
occurrence.transform = rigid(Vec3::ZERO, Vec3::ZERO, Vec3::X, std::f64::consts::FRAC_PI_2);
|
||||
let path = vec![occurrence.id];
|
||||
let kind = serde_json::from_value(serde_json::json!({
|
||||
"type": "box", "corner": [0, 0, 0], "length": "90", "width": "120", "height": "8"
|
||||
}))
|
||||
.unwrap();
|
||||
let plate = doc.add_feature(kind, Some("Plate")).unwrap();
|
||||
doc.feature_mut(plate).unwrap().component = comp;
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
let raw = state.bodies.first().unwrap();
|
||||
let index = raw.body.face_sources().unwrap().iter().position(|s| matches!(s, Some(kernel::FaceSource::Box { axis: 2, high: true, .. }))).unwrap();
|
||||
let world = world_state(&doc, &state);
|
||||
let shown = world.bodies.first().unwrap();
|
||||
let picked = shown.body.face_plane(index).unwrap();
|
||||
let anchor = doc.capture_face_anchor(raw, index, &picked, &path, 0, &[]).unwrap();
|
||||
let source = LinkSource::Face { body: raw.name.clone(), at: apply_point(&doc.component_transform(comp), Vec3::new(30., 60., 8.)) };
|
||||
let resolved = crate::project::resolve(&doc, &BTreeMap::new(), &world, &picked, LinkKind::Project, &source).unwrap();
|
||||
let plane = PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor.clone())) };
|
||||
let mut sketch = Sketch::new();
|
||||
let a = sketch.add_point(Vec2::ZERO, None).unwrap();
|
||||
let b = sketch.add_point(Vec2::new(3., 3.), None).unwrap();
|
||||
sketch.add_line_pts(a, b, None).unwrap();
|
||||
sketch.add_link(LinkKind::Project, source, &resolved.geom).unwrap();
|
||||
let id = doc.add_feature(FeatureKind::Sketch { plane, sketch }, None).unwrap();
|
||||
doc.feature_mut(id).unwrap().component = 0;
|
||||
let first = counted(|| model.evaluate(&doc));
|
||||
assert!(first.moved > 0, "{first:?}");
|
||||
assert!(model.result(id).unwrap().error.is_none(), "{:?}", model.result(id).unwrap().error);
|
||||
let reopened = Model::adopt(&doc, &model.saved(&doc).unwrap()).unwrap();
|
||||
for mut model in [model, reopened] {
|
||||
let before = state_in_face_anchor_frame(&doc, &model.state_before(id), &anchor, 0).unwrap().bodies.first().unwrap().mesh();
|
||||
|
||||
for to in [Vec2::new(4., 5.), Vec2::new(6., 7.)] {
|
||||
doc.sketch_mut(id).unwrap().points.get_mut(1).unwrap().pos = to;
|
||||
let work = counted(|| model.evaluate(&doc));
|
||||
assert_eq!(work.moved, 0, "sketch edit must reuse the unchanged placement: {work:?}");
|
||||
assert_eq!(model.last_recomputed, 1);
|
||||
let result = model.result(id).unwrap();
|
||||
assert!(result.error.is_none(), "{:?}", result.error);
|
||||
let solved = model.state();
|
||||
let solved = solved.sketch(id).unwrap();
|
||||
assert!(solved.sketch.links.iter().all(|link| !link.lost));
|
||||
assert!((solved.plane.normal().dot(picked.normal()) - 1.).abs() < 1e-9);
|
||||
let after = state_in_face_anchor_frame(&doc, &model.state_before(id), &anchor, 0).unwrap().bodies.first().unwrap().mesh();
|
||||
assert!(Arc::ptr_eq(&before, &after), "the anchored body's mesh must survive sketch edits");
|
||||
}
|
||||
// A changed placement is a different frame: the body is moved again, not reused.
|
||||
let turned = doc.occurrences.iter().find(|o| o.component == comp).unwrap().transform;
|
||||
doc.occurrences.iter_mut().find(|o| o.component == comp).unwrap().transform =
|
||||
rigid(Vec3::ZERO, Vec3::ZERO, Vec3::X, std::f64::consts::FRAC_PI_3);
|
||||
let work = counted(|| model.evaluate(&doc));
|
||||
assert!(work.moved > 0, "a changed placement must move the body again: {work:?}");
|
||||
doc.occurrences.iter_mut().find(|o| o.component == comp).unwrap().transform = turned;
|
||||
model.evaluate(&doc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imported_file_is_fingerprinted_by_its_text() {
|
||||
let doc = placed_imports(2);
|
||||
@@ -354,3 +423,55 @@ fn a_tool_cutting_another_part_cuts_again_when_that_part_moves() {
|
||||
assert_eq!(model.last_recomputed, 0);
|
||||
assert!((volume(&model, &body) - (400.0 - 16.0)).abs() < 1e-6, "{}", volume(&model, &body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_is_measured_once_while_it_stays_the_same() {
|
||||
let mut doc = Document::new("measured once");
|
||||
let cylinder = |doc: &mut Document, x: f64| {
|
||||
let kind = FeatureKind::Cylinder {
|
||||
base: point_expr(Vec3::new(x, 0.0, 0.0)),
|
||||
axis: Dir(Vec3::Z),
|
||||
radius: "2".into(),
|
||||
height: "10".into(),
|
||||
operation: Operation::NewBody,
|
||||
};
|
||||
doc.add_feature(kind, None).unwrap();
|
||||
};
|
||||
cylinder(&mut doc, 0.0);
|
||||
cylinder(&mut doc, 10.0);
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let measure_all = |st: &ModelState| {
|
||||
for b in &st.bodies {
|
||||
b.measure().unwrap();
|
||||
}
|
||||
};
|
||||
let first = model.state();
|
||||
assert_eq!(counted(|| measure_all(&first)).measures, 2);
|
||||
assert_eq!(counted(|| measure_all(&first)).measures, 0, "measured again");
|
||||
// A feature added: only its body is new.
|
||||
cylinder(&mut doc, 20.0);
|
||||
model.evaluate(&doc);
|
||||
let st = model.state();
|
||||
assert_eq!(counted(|| measure_all(&st)).measures, 1);
|
||||
// A body replaced in its holder is measured as it is now; the others keep theirs.
|
||||
let mut replaced = st.bodies[0].clone();
|
||||
replaced.body = kernel::box_solid(Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0)).unwrap();
|
||||
assert!((replaced.measure().unwrap().volume - 6.0).abs() < 1e-9);
|
||||
assert!((st.bodies[0].measure().unwrap().volume - first.bodies[0].measure().unwrap().volume).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_bodies_taken_as_before_keep_their_measure() {
|
||||
let mut doc = Document::new("placed measure");
|
||||
let component = doc.add_component(Some("Part"), 0).unwrap();
|
||||
doc.body_components.insert("Pin".into(), component);
|
||||
doc.occurrences[0].transform = rigid(Vec3::new(10.0, 0.0, 0.0), Vec3::ZERO, Vec3::Y, 0.3);
|
||||
let mut state = ModelState::default();
|
||||
state.bodies.push(ModelBody::new("Pin".into(), kernel::cylinder(Vec3::ZERO, Vec3::Z, 2.0, 8.0).unwrap(), 1));
|
||||
let mut cache = PlacedBodies::default();
|
||||
let before = world_state_reusing(&doc, &state, &mut cache);
|
||||
let centroid = before.bodies[0].measure().unwrap().centroid;
|
||||
let again = world_state_reusing(&doc, &state, &mut cache);
|
||||
assert_eq!(counted(|| assert_eq!(again.bodies[0].measure().unwrap().centroid, centroid)).measures, 0);
|
||||
}
|
||||
|
||||
@@ -620,6 +620,43 @@ pub(super) fn sheet_eval(
|
||||
tag_flanges(sh, n0, f.id);
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
FeatureKind::SheetPath { edge, sketch, curves, radius, body } => {
|
||||
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
if curves.is_empty() || curves.len() > sheet::MAX_PATH_LINES {
|
||||
return Err(DocError::Invalid(format!("a path flange's path must have 1…{} lines", sheet::MAX_PATH_LINES)));
|
||||
}
|
||||
for id in curves {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
|
||||
if !ss.sketch.segs(ci).iter().all(|s| matches!(s, Seg2::Line { .. })) {
|
||||
return Err(DocError::Invalid("not supported yet: arcs in a path flange's path (use lines; the bends are made for you)".into()));
|
||||
}
|
||||
}
|
||||
let path: Vec<Vec3> = chain_points(&ss, &ss.chain_order(curves)?)?.into_iter().map(|p| ss.plane.to_world(p)).collect();
|
||||
let i = sheet_for(st, body, Some(edge.0))?;
|
||||
let rr = match radius {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => {
|
||||
let rule = st.sheets.get(i).map(|s| s.rule.clone()).unwrap_or_default();
|
||||
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius
|
||||
}
|
||||
};
|
||||
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
if sh.flat {
|
||||
return Err(DocError::Invalid("refold the sheet before adding flanges".into()));
|
||||
}
|
||||
let pick = sheet_picks(sh, f, &[edge.0], warning).first().copied().unwrap_or(edge.0);
|
||||
let n0 = sh.flanges.len();
|
||||
let (_, notes) = sh.add_path_flange(pick, &path, rr, sheet::ReliefOverride::default())?;
|
||||
if !notes.is_empty() {
|
||||
let notes = notes.join("; ");
|
||||
*warning = Some(match warning.take() {
|
||||
Some(w) => format!("{w}; {notes}"),
|
||||
None => notes,
|
||||
});
|
||||
}
|
||||
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 {
|
||||
|
||||
+149
-5
@@ -255,7 +255,12 @@ impl Value {
|
||||
Kind::Angle if self.unitless() => self.v * d.ang,
|
||||
Kind::Unitless if self.unitless() => self.v,
|
||||
_ => {
|
||||
return Err(DocError::Expr(format!("expected {}, got {}", kind_word(k), self.describe())));
|
||||
let hint = if k == Kind::Unitless && self.len == 1 && self.ang == 0 {
|
||||
" (a count needs a unit-less value: divide by a length, e.g. `floor(L / (210 mm))`)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
return Err(DocError::Expr(format!("expected {}, got {}{hint}", kind_word(k), self.describe())));
|
||||
}
|
||||
};
|
||||
if out.is_finite() { Ok(out) } else { Err(DocError::Expr("result is not a finite number".into())) }
|
||||
@@ -503,6 +508,28 @@ fn is_reference(toks: &[Tok], i: usize, id: &str) -> bool {
|
||||
|
||||
/// Identifiers an expression refers to (excluding units, constants and functions).
|
||||
pub fn references(s: &str) -> Vec<String> {
|
||||
// Every evaluation fingerprints every feature by the parameters its expressions name, so the
|
||||
// same short expressions are asked about on every command: the answers are kept (per thread,
|
||||
// bounded) instead of being lexed again.
|
||||
thread_local! {
|
||||
static SEEN: std::cell::RefCell<std::collections::HashMap<String, Vec<String>>> = std::cell::RefCell::new(std::collections::HashMap::new());
|
||||
}
|
||||
if let Some(hit) = SEEN.with(|seen| seen.borrow().get(s).cloned()) {
|
||||
return hit;
|
||||
}
|
||||
let out = scan_references(s);
|
||||
SEEN.with(|seen| {
|
||||
let mut seen = seen.borrow_mut();
|
||||
if seen.len() >= 1 << 16 {
|
||||
seen.clear();
|
||||
}
|
||||
seen.insert(s.to_string(), out.clone());
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// [`references`], worked out.
|
||||
fn scan_references(s: &str) -> Vec<String> {
|
||||
let Ok(toks) = lex(s) else { return Vec::new() };
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (i, t) in toks.iter().enumerate() {
|
||||
@@ -562,8 +589,8 @@ fn is_name_shaped(s: &str) -> bool {
|
||||
matches!(cs.next(), Some(c) if c.is_alphabetic() || c == '_') && cs.all(|c| c.is_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/// A value typed as a definition, `name = expression`: the name and the text after the first
|
||||
/// `=`, both trimmed (the text may be empty). `None` for anything else, including text whose
|
||||
/// A value typed as a definition, `name = expression` (or `#name = expression`, #2371): the name
|
||||
/// without the `#` and the text after the first `=`, both trimmed (the text may be empty). `None` for anything else, including text whose
|
||||
/// left side is not shaped like a name (`2 = 3`), which stays an ordinary expression and fails
|
||||
/// to parse as before. The dimension input defines a user parameter this way (#2916); other
|
||||
/// value inputs can take the same form later.
|
||||
@@ -571,6 +598,7 @@ pub fn definition(s: &str) -> Option<(&str, &str)> {
|
||||
let (name, rhs) = s.split_once('=')?;
|
||||
let name = name.trim();
|
||||
let rhs = rhs.trim();
|
||||
let name = name.strip_prefix('#').unwrap_or(name);
|
||||
// `w == 3` is not a definition: a second `=` would read as a formula marker (#4681).
|
||||
(is_name_shaped(name) && !rhs.starts_with('=')).then_some((name, rhs))
|
||||
}
|
||||
@@ -919,7 +947,7 @@ pub fn eval_with_defaults(s: &str, lookup: &dyn Fn(&str) -> Result<Value, DocErr
|
||||
}
|
||||
|
||||
/// One parameter to evaluate: name, expression and unit (`mm`, `in`, `deg`, empty…).
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ParamDef {
|
||||
pub name: String,
|
||||
pub expr: String,
|
||||
@@ -930,6 +958,38 @@ pub struct ParamDef {
|
||||
/// name. Circular references are reported with their path (`a → b → a`). `doc_len` is the
|
||||
/// document's length unit in mm (bare numbers of unit-less-unit parameters next to lengths).
|
||||
pub fn eval_params(params: &[ParamDef], doc_len: f64) -> (BTreeMap<String, Value>, BTreeMap<String, String>) {
|
||||
// Each command evaluates the design's parameters (every named feature input is one) several
|
||||
// times over, mostly unchanged: the last few results are kept (per thread) for the same
|
||||
// definitions, compared in full.
|
||||
type Evaluated = (BTreeMap<String, Value>, BTreeMap<String, String>);
|
||||
thread_local! {
|
||||
static LAST: std::cell::RefCell<Vec<(Vec<ParamDef>, u64, Evaluated)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
let key = doc_len.to_bits();
|
||||
let hit = LAST.with(|last| {
|
||||
let mut last = last.borrow_mut();
|
||||
let at = last.iter().position(|(defs, len, _)| *len == key && defs.as_slice() == params)?;
|
||||
let entry = last.remove(at);
|
||||
let out = entry.2.clone();
|
||||
last.push(entry);
|
||||
Some(out)
|
||||
});
|
||||
if let Some(out) = hit {
|
||||
return out;
|
||||
}
|
||||
let out = eval_params_uncached(params, doc_len);
|
||||
LAST.with(|last| {
|
||||
let mut last = last.borrow_mut();
|
||||
if last.len() >= 4 {
|
||||
last.remove(0);
|
||||
}
|
||||
last.push((params.to_vec(), key, out.clone()));
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// [`eval_params`], worked out.
|
||||
fn eval_params_uncached(params: &[ParamDef], doc_len: f64) -> (BTreeMap<String, Value>, BTreeMap<String, String>) {
|
||||
let mut done: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut errors: BTreeMap<String, String> = BTreeMap::new();
|
||||
let index: BTreeMap<&str, &ParamDef> = params.iter().map(|p| (p.name.as_str(), p)).collect();
|
||||
@@ -975,6 +1035,9 @@ pub fn eval_params(params: &[ParamDef], doc_len: f64) -> (BTreeMap<String, Value
|
||||
cx.stack.pop();
|
||||
let r = match err {
|
||||
Some(e) => Err(e),
|
||||
// Most parameters are named feature inputs with a literal value ("10 mm"): those are
|
||||
// worked out once (per thread) rather than parsed again on every evaluation.
|
||||
None if vals.is_empty() => eval_constant(p, cx.doc_len),
|
||||
None => eval_def(p, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))), cx.doc_len),
|
||||
};
|
||||
match &r {
|
||||
@@ -1009,6 +1072,28 @@ pub fn eval_def(p: &ParamDef, lookup: &dyn Fn(&str) -> Result<Value, DocError>,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`eval_def`] of a parameter whose expression names no other parameter, remembered by its
|
||||
/// expression, unit and the design's length unit.
|
||||
fn eval_constant(p: &ParamDef, doc_len: f64) -> Result<Value, DocError> {
|
||||
type Key = (String, String, u64);
|
||||
thread_local! {
|
||||
static SEEN: std::cell::RefCell<std::collections::HashMap<Key, Result<Value, DocError>>> = std::cell::RefCell::new(std::collections::HashMap::new());
|
||||
}
|
||||
let key = (p.expr.clone(), p.unit.clone(), doc_len.to_bits());
|
||||
if let Some(hit) = SEEN.with(|seen| seen.borrow().get(&key).cloned()) {
|
||||
return hit;
|
||||
}
|
||||
let out = eval_def(p, &|n| Err(DocError::Expr(format!("unknown parameter `{n}`"))), doc_len);
|
||||
SEEN.with(|seen| {
|
||||
let mut seen = seen.borrow_mut();
|
||||
if seen.len() >= 1 << 16 {
|
||||
seen.clear();
|
||||
}
|
||||
seen.insert(key, out.clone());
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Dependency edges between parameters: (parameter, parameter it references).
|
||||
pub fn param_edges(params: &[ParamDef]) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
@@ -1140,6 +1225,16 @@ mod tests {
|
||||
assert_eq!(ev("width^2 / width", Kind::Length).unwrap(), 40.0);
|
||||
}
|
||||
|
||||
/// A count from a length (`(L - 20) / 210`, the 210 read as unit-less) names the fix (#5901).
|
||||
#[test]
|
||||
fn a_length_where_a_count_is_expected_says_to_divide_by_a_length() {
|
||||
let e = ev("width / 2", Kind::Unitless).unwrap_err().to_string();
|
||||
assert!(e.contains("expected a unit-less number, got a length") && e.contains("divide by a length"), "{e}");
|
||||
assert_eq!(ev("floor(width / (6 mm))", Kind::Unitless).unwrap(), 6.0);
|
||||
let e = ev("45 deg", Kind::Unitless).unwrap_err().to_string();
|
||||
assert!(!e.contains("divide"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
assert!(ev("width + ang", Kind::Length).is_err());
|
||||
@@ -1258,6 +1353,9 @@ mod tests {
|
||||
("w = 1 in - 5.4 mm", "w", "1 in - 5.4 mm"),
|
||||
("_w1 = 1", "_w1", "1"),
|
||||
("szerokość = 20", "szerokość", "20"),
|
||||
// A `#` before the name marks it as a new parameter's (#2371).
|
||||
("#w = 20", "w", "20"),
|
||||
(" #w=20 mm", "w", "20 mm"),
|
||||
// Reserved names split too: the parameter check names the reason.
|
||||
("mm = 5", "mm", "5"),
|
||||
("w = a = 3", "w", "a = 3"),
|
||||
@@ -1268,7 +1366,27 @@ mod tests {
|
||||
for (text, name, rhs) in splits {
|
||||
assert_eq!(definition(text), Some((name, rhs)), "{text:?}");
|
||||
}
|
||||
for text in ["20", "20 mm", "width / 2", "= 20", "=", "", "2w = 3", "a b = 3", "a-b = 3", "w.x = 3", "(w) = 3", "-w = 3", "w == 3"] {
|
||||
for text in [
|
||||
"20",
|
||||
"20 mm",
|
||||
"width / 2",
|
||||
"= 20",
|
||||
"=",
|
||||
"",
|
||||
"2w = 3",
|
||||
"a b = 3",
|
||||
"a-b = 3",
|
||||
"w.x = 3",
|
||||
"(w) = 3",
|
||||
"-w = 3",
|
||||
"# = 3",
|
||||
"#= 3",
|
||||
"##w = 3",
|
||||
"# w = 3",
|
||||
"#2w = 3",
|
||||
"#w",
|
||||
"w == 3",
|
||||
] {
|
||||
assert_eq!(definition(text), None, "{text:?}");
|
||||
}
|
||||
}
|
||||
@@ -1287,4 +1405,30 @@ mod tests {
|
||||
assert_eq!(crate::Document::param_name_problem("width"), None);
|
||||
assert!(problem("2w").contains("not a valid parameter name"));
|
||||
}
|
||||
|
||||
/// The kept answers of `references` and `eval_params` never stand for different input: an
|
||||
/// edited expression, unit or design length unit gives its own values.
|
||||
#[test]
|
||||
fn kept_parameter_values_follow_every_change() {
|
||||
assert_eq!(references("width * 2 + 3 mm"), vec!["width".to_string()]);
|
||||
assert_eq!(references("width * 2 + 3 mm"), vec!["width".to_string()]);
|
||||
assert!(references("10 mm").is_empty());
|
||||
let mm = |v: &BTreeMap<String, Value>, n: &str| v.get(n).map(|x| x.v);
|
||||
let mut defs = vec![def("a", "10 mm", "mm"), def("b", "a * 2", "mm"), def("c", "5", "mm")];
|
||||
let (first, _) = eval_params(&defs, 1.0);
|
||||
assert_eq!((mm(&first, "a"), mm(&first, "b"), mm(&first, "c")), (Some(10.0), Some(20.0), Some(5.0)));
|
||||
defs[0].expr = "12 mm".into();
|
||||
let (edited, _) = eval_params(&defs, 1.0);
|
||||
assert_eq!((mm(&edited, "a"), mm(&edited, "b")), (Some(12.0), Some(24.0)));
|
||||
defs[0].expr = "10 mm".into();
|
||||
assert_eq!(eval_params(&defs, 1.0).0, first, "back as it was");
|
||||
// Whatever changes, the kept answer is the one worked out afresh.
|
||||
for (unit, expr, len) in [("mm", "5", 25.4), ("", "5", 25.4), ("in", "5", 1.0), ("deg", "5", 1.0), ("", "5", 1.0)] {
|
||||
defs[2].unit = unit.into();
|
||||
defs[2].expr = expr.into();
|
||||
assert_eq!(eval_params(&defs, len), eval_params_uncached(&defs, len), "{unit} {expr} {len}");
|
||||
}
|
||||
defs[2].expr = "bad +".into();
|
||||
assert!(eval_params(&defs, 1.0).1.contains_key("c"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,9 @@ impl FaceAnchor {
|
||||
pub fn plane_face_anchor(plane: &PlaneRef) -> Option<&FaceAnchor> {
|
||||
match plane {
|
||||
PlaneRef::Face { anchor, .. } => anchor.as_deref(),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_face_anchor(base),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::ThroughPoint { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => {
|
||||
plane_face_anchor(base)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -91,7 +93,9 @@ pub fn plane_face_anchor(plane: &PlaneRef) -> Option<&FaceAnchor> {
|
||||
fn has_face_reference(plane: &PlaneRef) -> bool {
|
||||
match plane {
|
||||
PlaneRef::Face { .. } => true,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => has_face_reference(base),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::ThroughPoint { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => {
|
||||
has_face_reference(base)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -128,9 +132,10 @@ impl Document {
|
||||
*plane = PlaneRef::Custom { plane: resolved };
|
||||
Ok(note)
|
||||
}
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => {
|
||||
replace(doc, base, author, state, depth + 1)
|
||||
}
|
||||
PlaneRef::Offset { base, .. }
|
||||
| PlaneRef::ThroughPoint { base, .. }
|
||||
| PlaneRef::AtAngle { base, .. }
|
||||
| PlaneRef::Flipped { base, .. } => replace(doc, base, author, state, depth + 1),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,10 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
"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"}),
|
||||
),
|
||||
(
|
||||
"coil_profile",
|
||||
json!({"type": "coil", "base": ["coil_profile.x", "coil_profile.y", "coil_profile.z"], "sketch": 1, "profiles": {"type": "indices", "indices": [0]}, "pitch": "coil_profile.pitch", "turns": "coil_profile.turns"}),
|
||||
),
|
||||
("sheet_base", json!({"type": "sheet_base", "sketch": 1, "profiles": {"type": "all"}})),
|
||||
("sheet_contour", json!({"type": "sheet_contour", "sketch": 1, "curves": ["l1"], "distance": "contour.distance"})),
|
||||
(
|
||||
@@ -141,6 +145,7 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
"sheet_fold",
|
||||
json!({"type": "sheet_fold", "a": o, "b": [1, 0, 0], "angle": "fold.angle", "radius": "fold.radius", "position": "centerline"}),
|
||||
),
|
||||
("sheet_path", json!({"type": "sheet_path", "edge": o, "sketch": 1, "curves": ["l1"], "radius": "path.radius"})),
|
||||
("sheet_unfold", json!({"type": "sheet_unfold"})),
|
||||
("sheet_convert", json!({"type": "sheet_convert", "body": "B", "face": o})),
|
||||
(
|
||||
@@ -234,8 +239,8 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
|
||||
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" => {
|
||||
| "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_perforate" | "sheet_path" | "sheet_unfold"
|
||||
| "boss" | "lip" | "snap_fit" | "rest" | "surface_offset" => {
|
||||
json!({"body": "B"})
|
||||
}
|
||||
"combine" => json!({"target": "B", "tools": ["B"]}),
|
||||
|
||||
@@ -250,6 +250,11 @@ coil
|
||||
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
|
||||
coil_profile
|
||||
inputs:
|
||||
Pitch = coil_profile.pitch (Length)
|
||||
Revolutions = coil_profile.turns (Unitless)
|
||||
expressions: coil_profile.pitch, coil_profile.turns, coil_profile.x, coil_profile.y, coil_profile.z
|
||||
sheet_base
|
||||
sheet_contour
|
||||
inputs:
|
||||
@@ -281,6 +286,10 @@ sheet_fold
|
||||
Angle = fold.angle (Angle)
|
||||
Bend radius = fold.radius (Length)
|
||||
expressions: fold.angle, fold.radius
|
||||
sheet_path
|
||||
inputs:
|
||||
Bend radius = path.radius (Length)
|
||||
expressions: path.radius
|
||||
sheet_unfold
|
||||
sheet_convert
|
||||
boss
|
||||
|
||||
@@ -115,6 +115,10 @@ align
|
||||
coil
|
||||
moves: axis[0], axis[2], base[0], base[1], base[2]
|
||||
placement: coil.x, coil.y, coil.z
|
||||
coil_profile
|
||||
moves: axis[0], axis[2], base[0], base[1], base[2]
|
||||
profiles: 1 indices
|
||||
placement: coil_profile.x, coil_profile.y, coil_profile.z
|
||||
sheet_base
|
||||
profiles: 1 all
|
||||
sheet_contour
|
||||
@@ -125,6 +129,8 @@ sheet_hem
|
||||
sheet_perforate
|
||||
sheet_fold
|
||||
moves: a[0], a[1], a[2], b[1], b[2], fixed[0], fixed[1], fixed[2]
|
||||
sheet_path
|
||||
moves: edge[0], edge[1], edge[2]
|
||||
sheet_unfold
|
||||
sheet_convert
|
||||
moves: bends[0][0], bends[0][1], bends[0][2], face[0], face[1], face[2]
|
||||
|
||||
@@ -127,6 +127,9 @@ align
|
||||
remap_bodies: bodies[0]
|
||||
coil
|
||||
remap_bodies: targets[0]
|
||||
coil_profile
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
sheet_base
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
@@ -143,6 +146,10 @@ sheet_fold
|
||||
sketches: [7]
|
||||
remap_refs: sketch
|
||||
remap_bodies: body
|
||||
sheet_path
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
remap_bodies: body
|
||||
sheet_unfold
|
||||
remap_bodies: body
|
||||
sheet_convert
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! A sketch can hold two more dimensions to the edge of a circle or arc (#4064):
|
||||
//! `point_circle_distance`, from a point `p` to the edge of circle or arc `c` (saved with its
|
||||
//! `value` and, for a point within the circle, `inside`), and `circle_circle_distance`, between
|
||||
//! the edges of circles or arcs `a` and `b` (saved with its `value` and, unless they are apart,
|
||||
//! `circles`: `crossing` for how far they overlap, `nested` for the narrowest gap between one
|
||||
//! inside the other). Older designs need no transformation: they have no such dimensions; the
|
||||
//! last release asks for an update instead of meeting a constraint type it doesn't know.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A coil's section can come from a sketch (`sketch`, `profiles?`): the sketch plane is the
|
||||
//! section plane, and `diameter`/`section_size` are then absent. Older designs need no
|
||||
//! transformation: a coil without `sketch` is the primitive circular or square section exactly
|
||||
//! as before. The last release refuses a design saved with this (the newer format number).
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A construction plane can be another plane moved along its normal until it goes through a
|
||||
//! point (`through_point`: `base` and the `point` reference), so it follows the point. Older
|
||||
//! designs need no transformation: they have no such planes. The last release asks for an update
|
||||
//! instead of meeting a plane type it can't read.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Counterbore and countersink holes may store an optional nested `tip_angle` for the
|
||||
//! narrow bore's drill point. Missing values retain the previous flat bottom. No rewrite
|
||||
//! is needed; the entry recess and depth-to-shoulder semantics remain unchanged.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A design can hold a path flange (`sheet_path`): a chain of edge flanges on a sheet edge
|
||||
//! that follows an open chain of sketch lines drawn across it. Older designs need no
|
||||
//! transformation: they have none. The last release refuses a design saved with one (the newer
|
||||
//! format number).
|
||||
@@ -0,0 +1,6 @@
|
||||
//! A sheet metal rule can say how the bend angles of its parts read in the DXF bend notes
|
||||
//! (`angle_display`: `bend`, the default, or `included`, the angle left between a bend's two
|
||||
//! sides, 180° less the bend angle, as a press brake is set; #4968). It is saved only when it is
|
||||
//! `included`, in the rule and in the sheet bodies of the design's saved model. Older designs
|
||||
//! need no step: a rule or body without it reads its bends as bend angles, as before. The last
|
||||
//! release refuses a design saved with this (the newer format number).
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Tapped holes may store `thread_depth`, a length expression independent of the drill depth.
|
||||
//! An absent value retains the full useful cylindrical wall used by older designs. An explicit
|
||||
//! depth starts at that wall's entry, excludes the drill point and entry recess, and is refused
|
||||
//! when it is not positive and finite or exceeds the available cylindrical wall. Older readers
|
||||
//! would ignore the quota and describe a full-depth thread, so the design format must advance.
|
||||
@@ -428,6 +428,12 @@ pub(crate) fn matches(names: &Names, index: usize, name: &str) -> Option<Match>
|
||||
if info.is_some_and(|i| i.aliases.iter().any(|a| a == name)) {
|
||||
return Some(Match::Alias);
|
||||
}
|
||||
// A pattern copy's faces carry their instance after the name (`…@F9.1`, after any
|
||||
// qualifier): the names compare as the original's do, within the same copy only.
|
||||
let ((name, at), (current, here)) = (instance_suffix(name), instance_suffix(current));
|
||||
if at != here {
|
||||
return None;
|
||||
}
|
||||
// The named face (or piece) was split further: this is one of its pieces.
|
||||
if descends(current, name) {
|
||||
return Some(Match::Piece);
|
||||
@@ -443,6 +449,21 @@ pub(crate) fn matches(names: &Names, index: usize, name: &str) -> Option<Match>
|
||||
(crate::naming::strip_piece(current) == root).then_some(Match::Stem)
|
||||
}
|
||||
|
||||
/// A name and the pattern instances after it (`F3:top#[F4:side]@F9.1` is `F3:top#[F4:side]` and
|
||||
/// `@F9.1`; a copy of a copy carries one instance after another).
|
||||
fn instance_suffix(n: &str) -> (&str, &str) {
|
||||
let mut core = n;
|
||||
while let Some(at) = core.rfind('@') {
|
||||
let instance = core.get(at + 1..).and_then(|t| t.strip_prefix('F')).and_then(|t| t.split_once('.'));
|
||||
let digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit());
|
||||
if !instance.is_some_and(|(feature, k)| digits(feature) && digits(k)) {
|
||||
break;
|
||||
}
|
||||
core = core.get(..at).unwrap_or_default();
|
||||
}
|
||||
(core, n.get(core.len()..).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "history_names_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -44,6 +44,32 @@ fn pieces_are_told_apart_by_the_faces_only_they_touch() {
|
||||
assert_eq!(matches(&names, 2, "top"), None);
|
||||
}
|
||||
|
||||
/// A pattern's copy names its faces after the original's with the instance after them
|
||||
/// (`…@F9.1`, after any qualifier): a stored piece name of a copy finds its piece as the
|
||||
/// original's does, in that copy only.
|
||||
#[test]
|
||||
fn a_pattern_copys_piece_names_match_as_the_originals_do() {
|
||||
let faces = named(&["top#[left,slot:a]@F9.1", "top#[right,slot:b]@F9.1", "front@F9.1", "top#[left,slot:a]@F9.2", "top#[left,slot:a]"]);
|
||||
let names = qualify(faces, &[]);
|
||||
// The faces around the piece have changed since the name was stored (other slot walls).
|
||||
let stored = "top#[left,slot:c]@F9.1";
|
||||
assert_eq!(matches(&names, 0, stored), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 1, stored), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 2, stored), None);
|
||||
assert_eq!(matches(&names, 3, stored), None, "another copy");
|
||||
assert_eq!(matches(&names, 4, stored), None, "the original");
|
||||
assert_eq!(matches(&names, 0, "top@F9.1"), Some(Match::Piece), "a piece of the copy's face");
|
||||
assert_eq!(matches(&names, 0, "top#[left,slot:a]@F9.1"), Some(Match::Exact));
|
||||
// Copies of copies carry one instance after another.
|
||||
let names = qualify(named(&["top#[left]@F9.1@F12.2", "top#[left]@F9.1@F12.1"]), &[]);
|
||||
assert_eq!(matches(&names, 0, "top#[slot:c]@F9.1@F12.2"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 1, "top#[slot:c]@F9.1@F12.2"), None);
|
||||
// Hostile suffixes never panic.
|
||||
for n in ["@", "@F", "@F.", "@F1.", "a@F1.1@", "a#[b]@F1.1]", "@F1.1"] {
|
||||
let _ = matches(&names, 0, n);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_nothing_tells_apart_are_numbered_and_unverified() {
|
||||
let faces = named(&["top", "top", "ring"]);
|
||||
|
||||
+75
-22
@@ -351,11 +351,33 @@ impl JointExprs {
|
||||
/// Largest offset or value an expression may give a joint (mm, or radians for an angle).
|
||||
const MAX_EXPR_VALUE: f64 = 1e9;
|
||||
|
||||
/// A design's parameter values, worked out the first time an expression names one and kept for
|
||||
/// the next: a pass over the joints works out every joint's expressions again, and each would
|
||||
/// otherwise evaluate every parameter of the design anew.
|
||||
pub struct Params<'a> {
|
||||
doc: &'a Document,
|
||||
vals: std::cell::OnceCell<BTreeMap<String, crate::expr::Value>>,
|
||||
}
|
||||
|
||||
impl<'a> Params<'a> {
|
||||
pub fn new(doc: &'a Document) -> Params<'a> {
|
||||
Params { doc, vals: std::cell::OnceCell::new() }
|
||||
}
|
||||
|
||||
/// What [`Document::eval`] gives for `e`.
|
||||
pub fn eval(&self, e: &str, kind: crate::expr::Kind) -> Result<f64> {
|
||||
crate::expr::eval_with(e, &|n| {
|
||||
self.vals.get_or_init(|| self.doc.param_values().0).get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")))
|
||||
})?
|
||||
.to_kind(kind)
|
||||
}
|
||||
}
|
||||
|
||||
/// A joint's offset, angle and values worked out from its expressions (those without one keep
|
||||
/// their numbers), or why one can't be: an unknown name, a wrong unit, or a number too large.
|
||||
pub fn expressed(doc: &Document, j: &Joint) -> Result<(f64, f64, Vec<f64>)> {
|
||||
pub fn expressed(params: &Params, j: &Joint) -> Result<(f64, f64, Vec<f64>)> {
|
||||
let eval = |e: &str, kind: crate::expr::Kind, what: &str| -> Result<f64> {
|
||||
let v = doc.eval(e, kind).map_err(|err| DocError::Invalid(format!("joint {}: {what} `{e}`: {err}", j.name)))?;
|
||||
let v = params.eval(e, kind).map_err(|err| DocError::Invalid(format!("joint {}: {what} `{e}`: {err}", j.name)))?;
|
||||
if v.is_finite() && v.abs() < MAX_EXPR_VALUE { Ok(v) } else { Err(DocError::Invalid(format!("joint {}: {what} `{e}` gives {v}", j.name))) }
|
||||
};
|
||||
let x = &j.exprs;
|
||||
@@ -736,35 +758,42 @@ fn component_of(doc: &Document, occ: u64) -> Result<u64> {
|
||||
doc.occurrences.iter().find(|o| o.id == occ).map(|o| o.component).ok_or_else(|| DocError::Unknown(format!("occurrence {occ}")))
|
||||
}
|
||||
|
||||
/// A snap's pick point moved onto its named face (an error when the face is gone: a joint must
|
||||
/// not quietly attach elsewhere).
|
||||
fn named_pick(doc: &Document, st: &ModelState, pick: Vec3, face: Option<&str>) -> Result<Vec3> {
|
||||
/// A snap's pick point moved onto its named face of `component` (an error when the face is gone:
|
||||
/// a joint must not quietly attach elsewhere).
|
||||
fn named_pick(doc: &Document, st: &ModelState, component: u64, pick: Vec3, face: Option<&str>) -> Result<Vec3> {
|
||||
match face {
|
||||
Some(n) => face::named_face(doc, st, pick, n).map(|f| f.point),
|
||||
Some(n) => face::named_face(doc, st, component, pick, n).map(|f| f.point),
|
||||
None => Ok(pick),
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre and outward normal of the planar face nearest `pick` (component coordinates; a
|
||||
/// body moved in from another component placed by its offset, as a named face is).
|
||||
/// The centre (the middle of its outline, `face::outline_centre`) and outward normal of the
|
||||
/// planar face nearest `pick` (component coordinates; a body moved in from another component
|
||||
/// placed by its offset, as a named face is).
|
||||
fn nearest_plane(doc: &Document, st: &ModelState, o: &JointOrigin, pick: Vec3) -> Result<(Vec3, Vec3)> {
|
||||
let comp = component_of(doc, o.occurrence)?;
|
||||
let mut best: Option<(f64, Vec3, Vec3)> = None;
|
||||
let mut best: Option<(f64, &crate::ModelBody, solidcraft_kernel::FaceInfo, Vec3)> = None;
|
||||
for b in component_bodies(doc, st, comp) {
|
||||
let offset = doc.model_body_offset(b);
|
||||
for f in b.joint_faces()?.iter() {
|
||||
let Some(n) = f.plane_normal else { continue };
|
||||
let Some(own) = f.plane_normal else { continue };
|
||||
let (c, n) = match offset {
|
||||
Some(m) => (apply_point(m, f.centroid), apply_vector(m, n)),
|
||||
None => (f.centroid, n),
|
||||
Some(m) => (apply_point(m, f.centroid), apply_vector(m, own)),
|
||||
None => (f.centroid, own),
|
||||
};
|
||||
let d = (pick - c).dot(n).abs() + 1e-3 * (pick - c).len();
|
||||
if best.as_ref().is_none_or(|(bd, _, _)| d < *bd) {
|
||||
best = Some((d, c, n));
|
||||
if best.as_ref().is_none_or(|(bd, ..)| d < *bd) {
|
||||
best = Some((d, b, f.clone(), own));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, c, n)| (c, n)).ok_or_else(|| DocError::Invalid("joint origin: no planar face there".into()))
|
||||
let (_, b, f, n) = best.ok_or_else(|| DocError::Invalid("joint origin: no planar face there".into()))?;
|
||||
// The face's middle: its outline's on a face with holes.
|
||||
let c = face::outline_centre(&b.mesh(), f.index, f.centroid, n).unwrap_or(f.centroid);
|
||||
Ok(match doc.model_body_offset(b) {
|
||||
Some(m) => (apply_point(m, c), apply_vector(m, n)),
|
||||
None => (c, n),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a joint origin to a frame in its component's coordinates.
|
||||
@@ -776,20 +805,21 @@ pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Resul
|
||||
Snap::FaceCenter { pick, face, x } => {
|
||||
// A named planar face gives its own centre and normal; another face (or none) the
|
||||
// planar face nearest the point.
|
||||
let comp = component_of(doc, o.occurrence)?;
|
||||
let named = match face.as_deref() {
|
||||
Some(n) => Some(face::named_face(doc, st, *pick, n)?),
|
||||
Some(n) => Some(face::named_face(doc, st, comp, *pick, n)?),
|
||||
None => None,
|
||||
};
|
||||
let (c, n) = match named.as_ref().map(face::NamedFace::plane).transpose()?.flatten() {
|
||||
Some(cn) => cn,
|
||||
None => nearest_plane(doc, st, o, named.as_ref().map_or(*pick, |f| f.point))?,
|
||||
};
|
||||
frame(c, n, face::x_axis(doc, st, x.as_ref(), c)?).ok_or_else(bad)
|
||||
frame(c, n, face::x_axis(doc, st, comp, x.as_ref(), c)?).ok_or_else(bad)
|
||||
}
|
||||
Snap::Edge { .. } => edge::edge_frame(doc, st, component_of(doc, o.occurrence)?, &o.snap),
|
||||
Snap::CircleCenter { x, .. } => {
|
||||
let r = round_of(doc, st, o)?;
|
||||
frame(r.centre, r.z, face::x_axis(doc, st, x.as_ref(), r.centre)?).ok_or_else(bad)
|
||||
frame(r.centre, r.z, face::x_axis(doc, st, component_of(doc, o.occurrence)?, x.as_ref(), r.centre)?).ok_or_else(bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -799,13 +829,13 @@ pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Resul
|
||||
/// it, as older builds found them. Any other snap is refused.
|
||||
fn round_of(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result<face::Round> {
|
||||
let Snap::CircleCenter { pick, face, end, .. } = &o.snap else { return Err(DocError::Invalid("pick a round face".into())) };
|
||||
let comp = component_of(doc, o.occurrence)?;
|
||||
if let Some(n) = face.as_deref()
|
||||
&& let Some(r) = face::named_round(doc, st, *pick, n, end.as_deref())?
|
||||
&& let Some(r) = face::named_round(doc, st, comp, *pick, n, end.as_deref())?
|
||||
{
|
||||
return Ok(r);
|
||||
}
|
||||
let pick = named_pick(doc, st, *pick, face.as_deref())?;
|
||||
let comp = component_of(doc, o.occurrence)?;
|
||||
let pick = named_pick(doc, st, comp, *pick, face.as_deref())?;
|
||||
// A body moved in from another component: looked at in its own coordinates, its cylinder
|
||||
// placed by its offset (as a named face is).
|
||||
let cyl = component_bodies(doc, st, comp)
|
||||
@@ -1878,11 +1908,12 @@ fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Soluti
|
||||
.clone()
|
||||
.map_err(DocError::Invalid)
|
||||
};
|
||||
let params = Params::new(doc);
|
||||
for j in &joints {
|
||||
// A joint whose expressions can't be worked out (a parameter gone) is left out rather
|
||||
// than placed by numbers that no longer say what it was defined by.
|
||||
if !j.exprs.is_empty()
|
||||
&& let Err(e) = expressed(doc, j)
|
||||
&& let Err(e) = expressed(¶ms, j)
|
||||
{
|
||||
sol.errors.push((j.id, j.name.clone(), e.to_string()));
|
||||
continue;
|
||||
@@ -2204,6 +2235,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameter values kept between expressions give what evaluating each expression on
|
||||
/// its own gives, errors included.
|
||||
#[test]
|
||||
fn kept_parameters_evaluate_as_the_design_does() {
|
||||
let mut doc = Document::new("p");
|
||||
doc.set_param("W", "300 mm", None, None).unwrap();
|
||||
doc.set_param("H", "W / 2", None, None).unwrap();
|
||||
doc.set_param("A", "30 deg", None, None).unwrap();
|
||||
let params = Params::new(&doc);
|
||||
for (e, kind) in [
|
||||
("W / 4", crate::expr::Kind::Length),
|
||||
("H + 2 mm", crate::expr::Kind::Length),
|
||||
("A * 2", crate::expr::Kind::Angle),
|
||||
("12", crate::expr::Kind::Length),
|
||||
] {
|
||||
assert_eq!(params.eval(e, kind).unwrap(), doc.eval(e, kind).unwrap(), "{e}");
|
||||
}
|
||||
for (e, kind) in [("Q + 1", crate::expr::Kind::Length), ("A", crate::expr::Kind::Length)] {
|
||||
assert_eq!(params.eval(e, kind).unwrap_err().to_string(), doc.eval(e, kind).unwrap_err().to_string(), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: &Mat, b: &Mat) -> bool {
|
||||
let (t, r) = mismatch(a, b);
|
||||
t < 1e-9 && r < 1e-9
|
||||
|
||||
@@ -11,7 +11,15 @@
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{Snap, component_bodies, frame};
|
||||
use crate::{DocError, Document, Mat, ModelBody, ModelState, Result};
|
||||
use crate::{DocError, Document, IDENTITY, Mat, ModelBody, ModelState, Result, apply_point, mat_inverse, mat_mul};
|
||||
|
||||
/// A body's placement in its component and its inverse: a body moved in from another component
|
||||
/// (an imported part's) keeps its geometry where it was made, and its offset places it. Edges and
|
||||
/// faces are looked up in the body's own coordinates and the frame is placed back by the offset.
|
||||
fn placement(doc: &Document, b: &ModelBody) -> (Mat, Mat) {
|
||||
let m = doc.model_body_offset(b).copied().unwrap_or(IDENTITY);
|
||||
(m, mat_inverse(&m).unwrap_or(IDENTITY))
|
||||
}
|
||||
|
||||
/// Points that close are one corner (as a share of the body's size, at least 1 µm).
|
||||
fn tol(b: &ModelBody) -> f64 {
|
||||
@@ -103,16 +111,20 @@ pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap
|
||||
let Snap::Edge { pick, edge, face, corner } = snap else { return Err(DocError::Invalid("not an edge origin".into())) };
|
||||
let bad = || DocError::Invalid("joint origin: degenerate frame".into());
|
||||
let gone = |what: &str| DocError::Invalid(format!("joint origin: {what} no longer exists (edit the joint to pick again)"));
|
||||
let found = component_bodies(doc, st, comp).into_iter().find_map(|b| edge_index(b, edge.as_deref(), *pick).map(|i| (b, i)));
|
||||
let (b, i) = found.ok_or_else(|| match edge {
|
||||
let found = component_bodies(doc, st, comp).into_iter().find_map(|b| {
|
||||
let (m, inv) = placement(doc, b);
|
||||
let local = apply_point(&inv, *pick);
|
||||
edge_index(b, edge.as_deref(), local).map(|i| (b, i, local, m))
|
||||
});
|
||||
let (b, i, pick, placed) = found.ok_or_else(|| match edge {
|
||||
Some(n) => gone(&format!("the edge `{n}`")),
|
||||
None => DocError::Invalid("joint origin: no edge there".into()),
|
||||
})?;
|
||||
let m = b.mesh();
|
||||
let e = m.edges.get(i).ok_or_else(bad)?;
|
||||
let (_, z, centroid) = face_of(b, i, face.as_deref(), *pick)?;
|
||||
let (_, z, centroid) = face_of(b, i, face.as_deref(), pick)?;
|
||||
let flat = |v: Vec3| (v - z * v.dot(z)).normalized();
|
||||
match corner {
|
||||
let local = match corner {
|
||||
None => {
|
||||
let (mid, along) = middle(e).ok_or_else(bad)?;
|
||||
// Into the face: toward its centroid, square to the edge; a face whose centroid lies on
|
||||
@@ -122,7 +134,7 @@ pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap
|
||||
frame(mid, z, Some(y.cross(z))).ok_or_else(bad)
|
||||
}
|
||||
Some(other) => {
|
||||
let j = crate::name_lookup::locate_edge(b, other, *pick).map_err(|_| gone(&format!("the edge `{other}`")))?.edge;
|
||||
let j = crate::name_lookup::locate_edge(b, other, pick).map_err(|_| gone(&format!("the edge `{other}`")))?.edge;
|
||||
let f = m.edges.get(j).ok_or_else(bad)?;
|
||||
let t = tol(b);
|
||||
let at = meeting(e, f, t).ok_or_else(|| DocError::Invalid(format!("joint origin: its edges no longer meet at a corner (`{other}`)")))?;
|
||||
@@ -130,7 +142,8 @@ pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap
|
||||
let x = if z.dot(de.cross(df)) >= 0.0 { de } else { df };
|
||||
frame(at, z, Some(x)).ok_or_else(bad)
|
||||
}
|
||||
}
|
||||
}?;
|
||||
Ok(mat_mul(&placed, &local))
|
||||
}
|
||||
|
||||
/// An edge snap for an edge picked at `pick` on component `comp` (its own coordinates): on the
|
||||
@@ -138,11 +151,16 @@ pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap
|
||||
/// middle or, with `corner`, at the end nearest it. The edge, the face and the corner's other
|
||||
/// edge are kept by name.
|
||||
pub fn edge_snap(doc: &Document, st: &ModelState, comp: u64, pick: Vec3, on: Option<Vec3>, corner: Option<Vec3>) -> Result<Snap> {
|
||||
let (b, i) = component_bodies(doc, st, comp)
|
||||
let (b, i, inv) = component_bodies(doc, st, comp)
|
||||
.into_iter()
|
||||
.filter_map(|b| nearest_edge(b, pick).filter(|(_, d)| *d <= tol(b)).map(|(i, _)| (b, i)))
|
||||
.filter_map(|b| {
|
||||
let (_, inv) = placement(doc, b);
|
||||
nearest_edge(b, apply_point(&inv, pick)).filter(|(_, d)| *d <= tol(b)).map(|(i, _)| (b, i, inv))
|
||||
})
|
||||
.next()
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: no edge there".into()))?;
|
||||
// The picks in the body's own coordinates; the snap keeps the pick as given.
|
||||
let (local, on, corner) = (apply_point(&inv, pick), on.map(|p| apply_point(&inv, p)), corner.map(|c| apply_point(&inv, c)));
|
||||
let names = crate::naming::edge_names(b);
|
||||
let face_names = crate::naming::face_names(b);
|
||||
let m = b.mesh();
|
||||
@@ -158,7 +176,7 @@ pub fn edge_snap(doc: &Document, st: &ModelState, comp: u64, pick: Vec3, on: Opt
|
||||
}
|
||||
f
|
||||
}
|
||||
None => face_of(b, i, None, pick)?.0,
|
||||
None => face_of(b, i, None, local)?.0,
|
||||
};
|
||||
let face = face_names.get(face_index).cloned().filter(|n| !n.is_empty());
|
||||
let corner = match corner {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! A round origin's end is kept by the name of the face across the rim at that end, so it is the
|
||||
//! same end after the design changes; an x reference keeps the origin's turn about its axis.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::{Loop2, Mesh, Plane, Ring, Vec2, Vec3};
|
||||
use solidcraft_kernel::CylinderFace;
|
||||
|
||||
use super::{Snap, XRef, component_bodies};
|
||||
@@ -30,9 +30,23 @@ fn gone(name: &str) -> DocError {
|
||||
DocError::Invalid(format!("joint origin: the face `{name}` no longer exists (edit the joint to pick again)"))
|
||||
}
|
||||
|
||||
/// The face called `name` nearest `pick` (an error when it is gone: a joint must not quietly
|
||||
/// attach elsewhere).
|
||||
pub(super) fn named_face<'a>(doc: &Document, st: &'a ModelState, pick: Vec3, name: &str) -> Result<NamedFace<'a>> {
|
||||
/// The face called `name` on the bodies of `component` nearest `pick` (an error when it is gone:
|
||||
/// a joint must not quietly attach elsewhere). Each body is looked at in its own coordinates, and
|
||||
/// a face the name finds exactly comes before a piece of it or a face it was merged into. Only the
|
||||
/// component's own bodies: the parts of one STEP import share their face names, and another
|
||||
/// part's face of that name can lie nearer the pick than this part's own.
|
||||
pub(super) fn named_face<'a>(doc: &Document, st: &'a ModelState, component: u64, pick: Vec3, name: &str) -> Result<NamedFace<'a>> {
|
||||
let own = component_bodies(doc, st, component).into_iter().filter_map(|body| {
|
||||
let offset = doc.model_body_offset(body).copied().unwrap_or(IDENTITY);
|
||||
let (face, at, inexact) = crate::naming::face_on_body(body, name, apply_point(&mat_inverse(&offset)?, pick))?;
|
||||
let point = apply_point(&offset, at);
|
||||
Some(((inexact, point.dist(pick)), NamedFace { body, face, point, offset }))
|
||||
});
|
||||
if let Some((_, nf)) = own.min_by(|(a, _), (b, _)| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1))) {
|
||||
return Ok(nf);
|
||||
}
|
||||
// On none of them (the face's body has gone to another component since): wherever the name
|
||||
// is now.
|
||||
let hit = crate::name_lookup::locate_face(st, name, pick).ok_or_else(|| gone(name))?;
|
||||
let body = st.bodies.get(hit.body).ok_or_else(|| gone(name))?;
|
||||
let Some(offset) = doc.model_body_offset(body).copied() else {
|
||||
@@ -62,13 +76,65 @@ impl NamedFace<'_> {
|
||||
self.own_cylinder().map(|c| placed(&self.offset, c))
|
||||
}
|
||||
|
||||
/// The face's centre and outward normal (the component's coordinates), when it is planar.
|
||||
/// The face's centre (the middle of its outline, `outline_centre`) and outward normal (the
|
||||
/// component's coordinates), when it is planar.
|
||||
pub fn plane(&self) -> Result<Option<(Vec3, Vec3)>> {
|
||||
let faces = self.body.joint_faces()?;
|
||||
Ok(faces.get(self.face).and_then(|f| f.plane_normal.map(|n| (apply_point(&self.offset, f.centroid), apply_vector(&self.offset, n)))))
|
||||
Ok(faces.get(self.face).and_then(|f| {
|
||||
let n = f.plane_normal?;
|
||||
let c = outline_centre(&self.body.mesh(), self.face, f.centroid, n).unwrap_or(f.centroid);
|
||||
Some((apply_point(&self.offset, c), apply_vector(&self.offset, n)))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// The middle of the area the outer boundary of planar face `face` encloses (the body's own
|
||||
/// coordinates; `centroid` and `n` are the face's), for a face with holes: a hole cut into the
|
||||
/// face, moved or suppressed changes the face's centroid but not its outline, so a joint at the
|
||||
/// face's centre stays where it is. `None` for a face without holes (its centroid is that middle
|
||||
/// already) and for one whose mesh gives no clean outline (the caller keeps the centroid).
|
||||
pub(super) fn outline_centre(mesh: &Mesh, face: usize, centroid: Vec3, n: Vec3) -> Option<Vec3> {
|
||||
let plane = Plane::from_normal(centroid, n)?;
|
||||
let tris: Vec<[Vec3; 3]> =
|
||||
mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, f)| **f as usize == face).filter_map(|(t, _)| mesh.tri(t)).collect();
|
||||
// The face's triangles need not share vertex indices: their corners are matched by place.
|
||||
let size = tris.iter().flatten().fold(0.0_f64, |m, p| m.max(p.x.abs()).max(p.y.abs()).max(p.z.abs()));
|
||||
let q = (size * 1e-9).max(1e-12);
|
||||
let key = |p: Vec3| ((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64);
|
||||
let sides: BTreeMap<_, Vec3> = tris.iter().flat_map(|[a, b, c]| [(a, b), (b, c), (c, a)]).map(|(a, b)| ((key(*a), key(*b)), *b)).collect();
|
||||
// The outline: the sides no other triangle of the face runs back along, by the corner each
|
||||
// starts at (a corner starting two is where loops touch: no clean outline).
|
||||
let mut next = BTreeMap::new();
|
||||
for ((a, b), p) in &sides {
|
||||
if a != b && !sides.contains_key(&(*b, *a)) && next.insert(*a, (*b, *p)).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// Chained into loops (the face's plane coordinates); one that doesn't close is no outline.
|
||||
let (mut loops, mut seen): (Vec<Ring<Vec2>>, BTreeSet<_>) = (Vec::new(), BTreeSet::new());
|
||||
for start in next.keys() {
|
||||
let (mut at, mut ring) = (*start, Vec::new());
|
||||
while seen.insert(at) {
|
||||
let (to, p) = next.get(&at)?;
|
||||
ring.push(plane.to_local(*p));
|
||||
at = *to;
|
||||
}
|
||||
if ring.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if at != *start {
|
||||
return None;
|
||||
}
|
||||
loops.push(Ring::new(ring));
|
||||
}
|
||||
if loops.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// The outer boundary encloses the most area.
|
||||
let outer = loops.iter().max_by(|a, b| a.signed_area().abs().total_cmp(&b.signed_area().abs()))?;
|
||||
(outer.signed_area().abs() > q * q).then(|| plane.to_world(Loop2::polygon(outer).centroid()))
|
||||
}
|
||||
|
||||
/// A cylinder of a body's own coordinates placed by the body's (rigid) offset.
|
||||
pub(super) fn placed(m: &Mat, c: CylinderFace) -> CylinderFace {
|
||||
CylinderFace { axis_point: apply_point(m, c.axis_point), axis: apply_vector(m, *c.axis).unit().unwrap_or(c.axis), ..c }
|
||||
@@ -167,8 +233,8 @@ impl Round {
|
||||
/// A circle-centre snap's cylinder found through its named face (`None`: the snap names no
|
||||
/// face, or the face is not a cylinder any more; the caller looks by point, as older builds
|
||||
/// did). The end is the one whose rim meets the face called `end`, else the one nearer `pick`.
|
||||
pub(super) fn named_round(doc: &Document, st: &ModelState, pick: Vec3, face: &str, end: Option<&str>) -> Result<Option<Round>> {
|
||||
let nf = named_face(doc, st, pick, face)?;
|
||||
pub(super) fn named_round(doc: &Document, st: &ModelState, component: u64, pick: Vec3, face: &str, end: Option<&str>) -> Result<Option<Round>> {
|
||||
let nf = named_face(doc, st, component, pick, face)?;
|
||||
let Some(own) = nf.own_cylinder() else { return Ok(None) };
|
||||
let chosen = end.and_then(|name| end_named(nf.body, nf.face, &own, name)).unwrap_or_else(|| end_by_pick(&nf, &own));
|
||||
Ok(Some(Round::at(placed(&nf.offset, own), chosen)))
|
||||
@@ -196,13 +262,14 @@ fn end_named(b: &ModelBody, face: usize, own: &CylinderFace, name: &str) -> Opti
|
||||
}
|
||||
}
|
||||
|
||||
/// The x axis an x reference gives a frame at `o` with axis `z` (`None`: no reference).
|
||||
pub(super) fn x_axis(doc: &Document, st: &ModelState, x: Option<&XRef>, o: Vec3) -> Result<Option<Vec3>> {
|
||||
/// The x axis an x reference gives a frame at `o` (of `component`) with axis `z` (`None`: no
|
||||
/// reference).
|
||||
pub(super) fn x_axis(doc: &Document, st: &ModelState, component: u64, x: Option<&XRef>, o: Vec3) -> Result<Option<Vec3>> {
|
||||
Ok(match x {
|
||||
None => None,
|
||||
Some(XRef::Dir(d)) => Some(*d),
|
||||
Some(XRef::Face { face, pick }) => {
|
||||
let nf = named_face(doc, st, pick.unwrap_or(o), face)?;
|
||||
let nf = named_face(doc, st, component, pick.unwrap_or(o), face)?;
|
||||
if let Some((_, n)) = nf.plane()? {
|
||||
Some(n)
|
||||
} else if let Some(c) = nf.cylinder() {
|
||||
@@ -290,22 +357,22 @@ pub fn round_end_at(doc: &Document, st: &ModelState, component: u64, centre: Vec
|
||||
Some(Snap::CircleCenter { pick: centre, face: Some(face), end, x: None })
|
||||
}
|
||||
|
||||
/// Name the end a named circle-centre snap takes now (the face across the rim there), so it
|
||||
/// keeps that end when the design changes. Other snaps, and one whose end is named already, are
|
||||
/// left as they are.
|
||||
pub fn pin_end(doc: &Document, st: &ModelState, snap: &mut Snap) {
|
||||
/// Name the end a named circle-centre snap of `component` takes now (the face across the rim
|
||||
/// there), so it keeps that end when the design changes. Other snaps, and one whose end is named
|
||||
/// already, are left as they are.
|
||||
pub fn pin_end(doc: &Document, st: &ModelState, component: u64, snap: &mut Snap) {
|
||||
let Snap::CircleCenter { pick, face: Some(face), end: end @ None, .. } = snap else { return };
|
||||
let Ok(nf) = named_face(doc, st, *pick, face) else { return };
|
||||
let Ok(nf) = named_face(doc, st, component, *pick, face) else { return };
|
||||
let Some(own) = nf.own_cylinder() else { return };
|
||||
let (at, _) = end_by_pick(&nf, &own);
|
||||
*end = rim_neighbour(nf.body, nf.face, &own, at).and_then(|n| crate::naming::face_names(nf.body).get(n).cloned()).filter(|n| !n.is_empty());
|
||||
}
|
||||
|
||||
/// The names of the faces across the rims at the two ends of a named circle-centre snap's
|
||||
/// cylinder (what its `end` can be); empty when the snap names no cylinder.
|
||||
pub fn end_faces(doc: &Document, st: &ModelState, snap: &Snap) -> Vec<String> {
|
||||
/// The names of the faces across the rims at the two ends of the cylinder a named circle-centre
|
||||
/// snap of `component` names (what its `end` can be); empty when the snap names no cylinder.
|
||||
pub fn end_faces(doc: &Document, st: &ModelState, component: u64, snap: &Snap) -> Vec<String> {
|
||||
let Snap::CircleCenter { pick, face: Some(face), .. } = snap else { return Vec::new() };
|
||||
let Ok(nf) = named_face(doc, st, *pick, face) else { return Vec::new() };
|
||||
let Ok(nf) = named_face(doc, st, component, *pick, face) else { return Vec::new() };
|
||||
let Some(own) = nf.own_cylinder() else { return Vec::new() };
|
||||
let names = crate::naming::face_names(nf.body);
|
||||
ends(&own)
|
||||
|
||||
@@ -43,6 +43,7 @@ mod name_lookup;
|
||||
mod names;
|
||||
pub mod naming;
|
||||
mod params;
|
||||
pub mod persistent_name;
|
||||
pub mod physical_material;
|
||||
mod piece_names;
|
||||
pub mod pipe_thread_catalog;
|
||||
@@ -100,6 +101,8 @@ pub enum DocError {
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DocError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod coil_tests;
|
||||
#[cfg(test)]
|
||||
mod feature_walk_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -193,6 +193,16 @@ pub(crate) struct ProfileRoles<'a> {
|
||||
pub region: Option<&'a Region2>,
|
||||
/// The start and end caps' roles (`None`: not known).
|
||||
pub caps: [Option<String>; 2],
|
||||
/// A thin wall: its sides lie off the sketch curves, and are named by the curve they run
|
||||
/// beside.
|
||||
pub thin: Option<&'a ThinWallRoles>,
|
||||
}
|
||||
|
||||
/// What names the sides of a thin extrude's wall: the curves it follows and how far its sides lie
|
||||
/// from them (the thickness either side).
|
||||
pub(crate) struct ThinWallRoles {
|
||||
pub curves: Vec<String>,
|
||||
pub distances: [f64; 2],
|
||||
}
|
||||
|
||||
impl<'a> ProfileRoles<'a> {
|
||||
@@ -210,7 +220,12 @@ impl<'a> ProfileRoles<'a> {
|
||||
// Every curve the segment is drawn on (one, or several coincident or collinear ones).
|
||||
let curve = |segment: usize| -> Vec<String> {
|
||||
let (Some(curves), Some(segment)) = (curves.as_ref(), segments.get(segment)) else { return Vec::new() };
|
||||
curves.find_all_along(segment).into_iter().map(|id| format!("side:{id}")).collect()
|
||||
let drawn: Vec<String> = curves.find_all_along(segment).into_iter().map(|id| format!("side:{id}")).collect();
|
||||
match self.thin {
|
||||
// A thin wall's side beside its curve (the other side may lie on it).
|
||||
Some(thin) if drawn.is_empty() => curves.find_offset(segment, &thin.curves, thin.distances),
|
||||
_ => drawn,
|
||||
}
|
||||
};
|
||||
let roles = history
|
||||
.faces
|
||||
@@ -242,7 +257,7 @@ impl<'a> ProfileRoles<'a> {
|
||||
/// A body swept from a profile (revolve, sweep, loft): its sides named by the sketch curves,
|
||||
/// its caps `start` and `end`.
|
||||
pub fn swept(sketch: Option<&'a SolvedSketch>, region: Option<&'a Region2>) -> ProfileRoles<'a> {
|
||||
ProfileRoles { sketch, region, caps: [Some("start".into()), Some("end".into())] }
|
||||
ProfileRoles { sketch, region, caps: [Some("start".into()), Some("end".into())], thin: None }
|
||||
}
|
||||
|
||||
/// A tool built from the profile (and then moved, from `built`, when given), its faces named
|
||||
|
||||
@@ -22,7 +22,7 @@ pub struct SavedModel {
|
||||
|
||||
/// Records refer to these at most.
|
||||
const MAX_ITEMS: usize = 1_000_000;
|
||||
/// Bodies and sketches all the states hold together, at most (a state is a list of clones).
|
||||
/// Bodies, sketches and sheet bodies all the states hold together, at most (a state is a list of clones).
|
||||
const MAX_HELD: usize = 50_000_000;
|
||||
|
||||
/// A feature's boolean retries as saved. The kernel leaves `rejected` out when it is empty, and
|
||||
@@ -118,6 +118,10 @@ struct Writer {
|
||||
names_index: HashMap<usize, usize>,
|
||||
sketches: Vec<Json>,
|
||||
sketch_index: HashMap<usize, usize>,
|
||||
/// Sheet bodies by their record: most features leave the sheets as they were, so the states
|
||||
/// share them (writing them with every state made a sheet-metal design's model too large to keep).
|
||||
sheets: Vec<Json>,
|
||||
sheet_index: HashMap<String, usize>,
|
||||
states: Vec<Json>,
|
||||
state_index: HashMap<usize, usize>,
|
||||
}
|
||||
@@ -184,6 +188,17 @@ impl Writer {
|
||||
Some(self.sketches.len() - 1)
|
||||
}
|
||||
|
||||
fn sheet(&mut self, sheet: &crate::sheet::SheetBody) -> Option<usize> {
|
||||
let record = serde_json::to_value(sheet).ok()?;
|
||||
let key = record.to_string();
|
||||
if let Some(i) = self.sheet_index.get(&key) {
|
||||
return Some(*i);
|
||||
}
|
||||
self.sheets.push(record);
|
||||
self.sheet_index.insert(key, self.sheets.len() - 1);
|
||||
Some(self.sheets.len() - 1)
|
||||
}
|
||||
|
||||
fn state(&mut self, st: &Arc<ModelState>) -> Option<usize> {
|
||||
let id = Arc::as_ptr(st) as usize;
|
||||
if let Some(i) = self.state_index.get(&id) {
|
||||
@@ -191,10 +206,11 @@ impl Writer {
|
||||
}
|
||||
let bodies = st.bodies.iter().map(|b| self.model_body(b)).collect::<Option<Vec<_>>>()?;
|
||||
let sketches = st.sketches.iter().map(|s| self.sketch(s)).collect::<Option<Vec<_>>>()?;
|
||||
let sheets = st.sheets.iter().map(|s| self.sheet(s)).collect::<Option<Vec<_>>>()?;
|
||||
let r = json!({
|
||||
"bodies": runs(&bodies), "sketches": runs(&sketches), "body_counter": st.body_counter,
|
||||
"reserved_body_names": st.reserved_body_names.iter().collect::<Vec<_>>(),
|
||||
"sheets": serde_json::to_value(&st.sheets).ok()?, "construct": serde_json::to_value(&st.construct).ok()?,
|
||||
"sheets": sheets, "construct": serde_json::to_value(&st.construct).ok()?,
|
||||
"threads": serde_json::to_value(&st.threads).ok()?,
|
||||
"sketch_keys": st.sketch_keys.iter().map(|(f, k)| json!([f, hex(*k)])).collect::<Vec<_>>(),
|
||||
"blend_edges": serde_json::to_value(&st.blend_edges).ok()?, "fitted": serde_json::to_value(&st.fitted).ok()?,
|
||||
@@ -230,6 +246,7 @@ impl Model {
|
||||
}
|
||||
let records = json!({
|
||||
"features": features, "states": w.states, "model_bodies": w.model_bodies, "names": w.names, "sketches": w.sketches,
|
||||
"sheets": w.sheets,
|
||||
});
|
||||
Ok(SavedModel { records, bodies: w.bodies })
|
||||
})
|
||||
@@ -306,6 +323,10 @@ impl SavedModel {
|
||||
}
|
||||
model_bodies.push(b);
|
||||
}
|
||||
let mut sheets = Vec::new();
|
||||
for s in list("sheets")? {
|
||||
sheets.push(serde_json::from_value::<crate::sheet::SheetBody>(s.clone()).map_err(|_| bad("saved model: sheet"))?);
|
||||
}
|
||||
let mut states = Vec::new();
|
||||
let mut held = 0usize;
|
||||
for s in list("states")? {
|
||||
@@ -330,6 +351,7 @@ impl SavedModel {
|
||||
.insert(k.get(0).and_then(Json::as_u64).ok_or_else(|| bad("saved model: state"))?, unhex(k.get(1).unwrap_or(&Json::Null))?);
|
||||
}
|
||||
let st = ModelState {
|
||||
frame_bodies: Default::default(),
|
||||
bodies: ids("bodies", model_bodies.len())?.into_iter().filter_map(|i| model_bodies.get(i).cloned()).collect(),
|
||||
sketches: ids("sketches", sketches.len())?.into_iter().filter_map(|i| sketches.get(i).cloned()).collect(),
|
||||
body_counter: index(s.get("body_counter"), usize::MAX)?,
|
||||
@@ -338,7 +360,13 @@ impl SavedModel {
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().filter_map(|n| n.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default(),
|
||||
sheets: serde_json::from_value(field("sheets")?).map_err(invalid)?,
|
||||
sheets: s
|
||||
.get("sheets")
|
||||
.and_then(Json::as_array)
|
||||
.ok_or_else(|| bad("saved model: state"))?
|
||||
.iter()
|
||||
.map(|i| index(Some(i), sheets.len()).and_then(|i| sheets.get(i).cloned().ok_or_else(|| bad("saved model: sheet"))))
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
construct: serde_json::from_value(field("construct")?).map_err(invalid)?,
|
||||
threads: serde_json::from_value(field("threads")?).map_err(invalid)?,
|
||||
sketch_keys,
|
||||
@@ -346,7 +374,7 @@ impl SavedModel {
|
||||
fitted: serde_json::from_value(field("fitted")?).map_err(invalid)?,
|
||||
tapered: serde_json::from_value(field("tapered")?).map_err(invalid)?,
|
||||
};
|
||||
held = held.saturating_add(st.bodies.len()).saturating_add(st.sketches.len());
|
||||
held = held.saturating_add(st.bodies.len()).saturating_add(st.sketches.len()).saturating_add(st.sheets.len());
|
||||
if held > MAX_HELD {
|
||||
return Err(bad("saved model: states"));
|
||||
}
|
||||
@@ -423,7 +451,9 @@ impl SavedModel {
|
||||
let mut kept_bodies = vec![false; model_bodies.len()];
|
||||
let (mut names_used, mut sketches_used) = (vec![false; names.len()], vec![false; sketches.len()]);
|
||||
let mut stored = std::collections::HashSet::new();
|
||||
let mut used = 0usize;
|
||||
// The sheet bodies, each written once and named by place in the states: all kept.
|
||||
let sheets = list("sheets").cloned().unwrap_or_default();
|
||||
let mut used = bytes(sheets.as_slice()).iter().fold(0usize, |t, b| t.saturating_add(*b));
|
||||
for s in std::iter::once(last).chain(order) {
|
||||
let (mut adds, mut new_names, mut new_sketches) = (HashMap::new(), std::collections::HashSet::new(), std::collections::HashSet::new());
|
||||
let mut records = *state_bytes.get(s)?;
|
||||
@@ -515,7 +545,7 @@ impl SavedModel {
|
||||
}
|
||||
let records = json!({
|
||||
"features": out_features, "states": out_states, "model_bodies": out_bodies, "names": kept_of(names.as_slice(), &names_used),
|
||||
"sketches": kept_of(sketches.as_slice(), &sketches_used),
|
||||
"sketches": kept_of(sketches.as_slice(), &sketches_used), "sheets": sheets,
|
||||
});
|
||||
let kept: Vec<usize> = (0..self.bodies.len()).filter(|i| kernel_used.get(*i) == Some(&true)).collect();
|
||||
let bodies = kept.iter().filter_map(|i| self.bodies.get(*i).cloned()).collect();
|
||||
@@ -529,7 +559,11 @@ impl Model {
|
||||
/// did not keep ([`SavedModel::thinned`]). Refused (an error) unless every feature's
|
||||
/// fingerprint is the one `doc` gives it now, and every record is well formed.
|
||||
pub fn adopt(doc: &Document, saved: &SavedModel) -> Result<Model> {
|
||||
let states = saved.states()?;
|
||||
let mut states = saved.states()?;
|
||||
let frame_bodies = Arc::new(std::sync::Mutex::new(FrameBodies::default()));
|
||||
for state in &mut states {
|
||||
Arc::make_mut(state).frame_bodies = Arc::downgrade(&frame_bodies);
|
||||
}
|
||||
let records = &saved.records;
|
||||
let index = |v: Option<&Json>, len: usize| {
|
||||
v.and_then(Json::as_u64).and_then(|i| usize::try_from(i).ok()).filter(|i| *i < len).ok_or_else(|| bad("saved model: index"))
|
||||
@@ -584,7 +618,7 @@ impl Model {
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
let mut model = Model { results, param_errors, step_digests, ..Model::default() };
|
||||
let mut model = Model { results, param_errors, step_digests, frame_bodies, ..Model::default() };
|
||||
let unkept = model.results.iter().filter(|r| r.unkept).count();
|
||||
if unkept > 0 {
|
||||
// The features whose state the file did not keep, each evaluated again from the state
|
||||
|
||||
@@ -163,6 +163,10 @@ const MAX_DEPTH: usize = 400;
|
||||
/// Up to this many sample points per face.
|
||||
const SAMPLES: usize = 9;
|
||||
|
||||
/// Up to this many sample points per face where a sheet's panel kept in place names the face it is
|
||||
/// part of (see [`geometric_from`]).
|
||||
const PANEL_SAMPLES: usize = 256;
|
||||
|
||||
/// Unit normals of each face's triangles.
|
||||
fn normals(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
|
||||
let mut per: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
@@ -178,6 +182,11 @@ fn normals(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
|
||||
|
||||
/// Triangle centroids spread over each face: face → points.
|
||||
fn samples(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
|
||||
samples_up_to(m, nf, SAMPLES)
|
||||
}
|
||||
|
||||
/// [`samples`] with up to `most` points per face.
|
||||
fn samples_up_to(m: &Mesh, nf: usize, most: usize) -> Vec<Vec<Vec3>> {
|
||||
let mut per: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if let (Some(v), Some([a, b, c])) = (per.get_mut(*f as usize), m.tri(t)) {
|
||||
@@ -186,11 +195,11 @@ fn samples(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
|
||||
}
|
||||
per.into_iter()
|
||||
.map(|v| {
|
||||
if v.len() <= SAMPLES {
|
||||
if v.len() <= most {
|
||||
return v;
|
||||
}
|
||||
let step = v.len() as f64 / SAMPLES as f64;
|
||||
(0..SAMPLES).filter_map(|i| v.get((i as f64 * step) as usize).copied()).collect()
|
||||
let step = v.len() as f64 / most as f64;
|
||||
(0..most).filter_map(|i| v.get((i as f64 * step) as usize).copied()).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -442,12 +451,26 @@ fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&Mode
|
||||
inputs.iter().find(|(x, _, _)| x.name == b.name),
|
||||
) {
|
||||
let input = std::slice::from_ref(input);
|
||||
for (name, ps) in names.iter_mut().zip(&pts) {
|
||||
let mut dense: Option<Vec<Vec<Vec3>>> = None;
|
||||
for (fi, (name, ps)) in names.iter_mut().zip(&pts).enumerate() {
|
||||
if name.is_some() {
|
||||
continue;
|
||||
}
|
||||
let back: Option<Vec<Vec3>> = ps.iter().map(|p| after.point_before(before, *p)).collect();
|
||||
if let Some(back) = back.filter(|b| !b.is_empty()) {
|
||||
// The panel the feature kept in place (the base an unfold lays the flanges out beside)
|
||||
// lies on its own face before, and that face names the whole face it is part of now:
|
||||
// a flat face spans that panel, the panels laid out beside it and the bend strips
|
||||
// between them, whatever share of it they take. The few sample points above may all
|
||||
// miss it (a bend strip is many small triangles), so the face is looked at closer.
|
||||
let dense = dense.get_or_insert_with(|| samples_up_to(&m, nf, PANEL_SAMPLES));
|
||||
if let Some(kept) = dense.get(fi).and_then(|ps| plurality(ps, input, tol)) {
|
||||
*name = Some(kept);
|
||||
continue;
|
||||
}
|
||||
// A point on a bend lies on no panel and has no place before; the face's other points
|
||||
// do. At least half of them must, so a face that is mostly bend is not named from a
|
||||
// few points.
|
||||
let back: Vec<Vec3> = ps.iter().filter_map(|p| after.point_before(before, *p)).collect();
|
||||
if !back.is_empty() && back.len() * 2 >= ps.len() {
|
||||
*name = vote(&back, input, tol, None);
|
||||
}
|
||||
}
|
||||
@@ -554,6 +577,28 @@ fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)], tol:
|
||||
votes.first().filter(|(_, n)| *n * 2 >= ps.len().max(1) && votes.get(1).is_none_or(|(_, other)| n > other)).map(|(n, _)| n.clone())
|
||||
}
|
||||
|
||||
/// The input face the most of `ps` lie on (pieces counted as their face), if at least two and a
|
||||
/// sixteenth of them lie on one: the face a sheet's unmoved panel was part of.
|
||||
fn plurality(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)], tol: f64) -> Option<String> {
|
||||
let mut votes: Vec<(String, usize)> = Vec::new();
|
||||
for p in ps {
|
||||
for (_, idx, nm) in inputs {
|
||||
if let Some(f) = idx.face_at(*p, tol)
|
||||
&& let Some(n) = nm.get(f)
|
||||
{
|
||||
let n = strip_piece(n);
|
||||
match votes.iter_mut().find(|(v, _)| v == n) {
|
||||
Some(v) => v.1 += 1,
|
||||
None => votes.push((n.to_string(), 1)),
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
votes.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
votes.first().filter(|(_, n)| *n >= 2 && *n * 16 >= ps.len()).map(|(n, _)| n.clone())
|
||||
}
|
||||
|
||||
/// A face name without its piece qualifiers: the face it is a piece of (`F2:top` for
|
||||
/// `F2:top#[F4:side:l1]`, for pieces of pieces, and for the numbered pieces of older designs).
|
||||
pub fn strip_piece(n: &str) -> &str {
|
||||
|
||||
+16
-10
@@ -136,16 +136,12 @@ impl Document {
|
||||
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 !taken.contains(c.as_str()) {
|
||||
return c;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
// Their numbers, so trying d1, d2 … writes no names: a design with thousands of named
|
||||
// inputs wrote that many for every new one.
|
||||
let names = self.params.iter().map(|p| p.name.as_str()).chain(self.features.iter().flat_map(|f| f.param_names.iter().map(String::as_str)));
|
||||
let taken = numbers_after("d", names);
|
||||
let n = (1..).find(|n| !taken.contains(n)).unwrap_or(1);
|
||||
format!("d{n}")
|
||||
}
|
||||
|
||||
/// Give a feature's inputs parameter names (keeping existing names in place).
|
||||
@@ -1016,3 +1012,13 @@ pub fn cycles(list: &[(String, String, Kind)]) -> BTreeSet<String> {
|
||||
}
|
||||
bad
|
||||
}
|
||||
|
||||
/// The numbers `n` for which `{base}{n}` is among `names` (written as `n` is: no sign or
|
||||
/// leading zeros).
|
||||
pub(crate) fn numbers_after<'a>(base: &str, names: impl Iterator<Item = &'a str>) -> std::collections::HashSet<u64> {
|
||||
names
|
||||
.filter_map(|name| name.strip_prefix(base))
|
||||
.filter(|digits| !digits.is_empty() && !digits.starts_with('0') && digits.bytes().all(|b| b.is_ascii_digit()))
|
||||
.filter_map(|digits| digits.parse().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Persistent face and edge names as values, and their text form.
|
||||
//!
|
||||
//! A face name says what made the face: a feature and its role there (`F2:top`, `F2:side:l4`), a
|
||||
//! fillet or chamfer and the edge it rounds (`F5:blend:<edge>`), a shell and the face it offsets
|
||||
//! (`F3:inner:<face>`), or, without history, a body and an index (`Body1:face:3`). Suffixes
|
||||
//! follow: a pattern copy (`@F7.2`), a piece of a split face told apart by the faces around it
|
||||
//! (`#[<face>,…]`, `#k` after it for a tie), an older design's numbered piece (`#k`). An edge is
|
||||
//! the two faces it separates, `~k` for one of several between them.
|
||||
//!
|
||||
//! The text form, read by [`FaceName::parse`] and [`EdgeName::parse`] and written by `Display`:
|
||||
//!
|
||||
//! ```text
|
||||
//! edge = face { "|" face } [TIE] one face for an open boundary, more where several meet
|
||||
//! face = base { suffix }
|
||||
//! base = FEATURE ":" "blend" ":" ( edge | NUMBER )
|
||||
//! | FEATURE ":" "inner" ":" face
|
||||
//! | FEATURE ":" role
|
||||
//! | text (`<body>:face:<k>`, or any other text)
|
||||
//! suffix = COPY | "#[" face { "," face } "]" [HASH] | HASH
|
||||
//! FEATURE = "F" NUMBER COPY = "@F" NUMBER "." NUMBER
|
||||
//! HASH = "#" NUMBER TIE = "~" NUMBER
|
||||
//! ```
|
||||
//!
|
||||
//! Suffixes after a nested name are ambiguous in the text: in `F5:blend:A|B#[…]` the qualifier
|
||||
//! may split `B` (the fillet rounds an edge of a piece made before it) or the fillet face itself
|
||||
//! (a later cut split it), and so for a shell's inner wall `F3:inner:X#[…]`. A suffix naming a
|
||||
//! feature later than the outer face's belongs to the outer face; any other stays inside, so a
|
||||
//! later cut told apart only by older faces reads as a piece of the inner name. A `~k` ending an
|
||||
//! edge numbers that edge, not a blend's edge inside it.
|
||||
//!
|
||||
//! Finding a face by name still goes through `naming::strip_piece` and `parent_piece`, which
|
||||
//! read a trailing qualifier as the whole name's; they move here once the text form says where a
|
||||
//! qualifier belongs.
|
||||
//!
|
||||
//! Reading is bounded in length and depth and never panics; whatever it reads writes back as the
|
||||
//! same text.
|
||||
|
||||
/// The longest name read.
|
||||
/// The longest name read. Names of pieces list their neighbours and grow with every split: a face
|
||||
/// engraved with text passes 4 KiB.
|
||||
pub const MAX_NAME_LEN: usize = 1 << 20;
|
||||
/// The deepest nesting read (blends of blends, pieces of pieces).
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
/// A feature of the timeline, as names refer to it (`F12`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct FeatureId(pub u64);
|
||||
|
||||
/// A persistent face name.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FaceName {
|
||||
pub base: Base,
|
||||
/// Innermost first.
|
||||
pub suffixes: Vec<Suffix>,
|
||||
}
|
||||
|
||||
/// What made a face.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Base {
|
||||
/// `F<feature>:<role>`.
|
||||
Made { feature: FeatureId, role: Role },
|
||||
/// `F<feature>:blend:…`: a fillet or chamfer face.
|
||||
Blend { feature: FeatureId, rounds: Rounds },
|
||||
/// `F<feature>:inner:<face>`: a shell's inner wall.
|
||||
Inner { feature: FeatureId, offsets: Box<FaceName> },
|
||||
/// `<body>:face:<k>`: a face of a body without history, by index.
|
||||
BodyFace { body: String, index: u32 },
|
||||
/// Text of no form above.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// What a blend face rounds.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Rounds {
|
||||
/// The edge, by name.
|
||||
Edge(Box<EdgeName>),
|
||||
/// The edge's index among the feature's edges (no name was known).
|
||||
Index(u32),
|
||||
}
|
||||
|
||||
/// A face's role in the feature that made it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
Top,
|
||||
Bottom,
|
||||
Start,
|
||||
End,
|
||||
Base,
|
||||
Section,
|
||||
/// `side`: a cylinder's wall, or a tool's faces swept from its whole profile.
|
||||
Wall,
|
||||
/// `side:<curve>`, `side:s<k>.<curve>` for profile `k`: swept from a sketch curve.
|
||||
Side {
|
||||
profile: Option<u32>,
|
||||
curve: String,
|
||||
},
|
||||
/// `+x`, `-z`, …: a box's face.
|
||||
BoxFace {
|
||||
axis: Axis,
|
||||
high: bool,
|
||||
},
|
||||
/// `face:<k>`: the feature's face `k`, named from geometry.
|
||||
Index(u32),
|
||||
/// A role of another form (a cap's, a tool part's, a sheet's), as written.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Axis {
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
}
|
||||
|
||||
/// What follows a face's base.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Suffix {
|
||||
/// `@F<feature>.<instance>`: a pattern or mirror copy.
|
||||
Copy { feature: FeatureId, instance: u32 },
|
||||
/// `#[…]`, `#k` after it: a piece of a split face.
|
||||
Piece(Qualifier),
|
||||
/// `#k`: a numbered piece of a design saved before format 14.
|
||||
Numbered(u32),
|
||||
}
|
||||
|
||||
/// The faces only one piece of a split face touches, and its number among pieces they don't
|
||||
/// tell apart.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Qualifier {
|
||||
around: Vec<FaceName>,
|
||||
pub tie: Option<u32>,
|
||||
}
|
||||
|
||||
/// A persistent edge name.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EdgeName {
|
||||
/// The faces it separates, in order: two, one for an open boundary, more where several
|
||||
/// faces meet.
|
||||
pub faces: Vec<FaceName>,
|
||||
pub tie: Option<u32>,
|
||||
}
|
||||
|
||||
/// Why a name could not be read.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum NameError {
|
||||
TooLong,
|
||||
TooDeep,
|
||||
/// At byte `at`, something else was expected.
|
||||
Expected {
|
||||
at: usize,
|
||||
what: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
impl FaceName {
|
||||
/// The feature that made the face: its base's, a copy's when it is one.
|
||||
pub fn feature(&self) -> Option<FeatureId> {
|
||||
let copied = self.suffixes.iter().rev().find_map(|s| match s {
|
||||
Suffix::Copy { feature, .. } => Some(*feature),
|
||||
_ => None,
|
||||
});
|
||||
copied.or(match &self.base {
|
||||
Base::Made { feature, .. } | Base::Blend { feature, .. } | Base::Inner { feature, .. } => Some(*feature),
|
||||
Base::BodyFace { .. } | Base::Other(_) => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The face this one is a piece of: the trailing piece suffixes off.
|
||||
pub fn stem(&self) -> FaceName {
|
||||
let keep = self.suffixes.iter().rposition(|s| matches!(s, Suffix::Copy { .. })).map_or(0, |i| i + 1);
|
||||
FaceName { base: self.base.clone(), suffixes: self.suffixes.get(..keep).unwrap_or_default().to_vec() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Qualifier {
|
||||
/// A qualifier listing `around`, at least one face.
|
||||
pub fn new(around: Vec<FaceName>, tie: Option<u32>) -> Option<Qualifier> {
|
||||
(!around.is_empty()).then_some(Qualifier { around, tie })
|
||||
}
|
||||
|
||||
pub fn around(&self) -> &[FaceName] {
|
||||
&self.around
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a feature id sits in a name.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Place {
|
||||
/// It names what made a face: a base, a nested face's base, a copy.
|
||||
Name,
|
||||
/// It names a face around a piece (`#[…]`).
|
||||
Qualifier,
|
||||
}
|
||||
|
||||
impl FaceName {
|
||||
/// The feature whose `F<n>:` starts the name.
|
||||
pub fn made_by(&self) -> Option<FeatureId> {
|
||||
match &self.base {
|
||||
Base::Made { feature, .. } | Base::Blend { feature, .. } | Base::Inner { feature, .. } => Some(*feature),
|
||||
Base::BodyFace { .. } | Base::Other(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every feature id in the name, in the order the text gives them.
|
||||
pub fn features(&self) -> Vec<FeatureId> {
|
||||
let mut out = Vec::new();
|
||||
self.push_features(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn push_features(&self, out: &mut Vec<FeatureId>) {
|
||||
match &self.base {
|
||||
Base::Made { feature, .. } | Base::Blend { feature, rounds: Rounds::Index(_) } => out.push(*feature),
|
||||
Base::Blend { feature, rounds: Rounds::Edge(e) } => {
|
||||
out.push(*feature);
|
||||
e.faces.iter().for_each(|f| f.push_features(out));
|
||||
}
|
||||
Base::Inner { feature, offsets } => {
|
||||
out.push(*feature);
|
||||
offsets.push_features(out);
|
||||
}
|
||||
Base::BodyFace { .. } | Base::Other(_) => {}
|
||||
}
|
||||
for s in &self.suffixes {
|
||||
match s {
|
||||
Suffix::Copy { feature, .. } => out.push(*feature),
|
||||
Suffix::Piece(q) => q.around.iter().for_each(|f| f.push_features(out)),
|
||||
Suffix::Numbered(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fillet's or chamfer's face (a piece or copy of one too).
|
||||
pub fn is_blend(&self) -> bool {
|
||||
matches!(self.base, Base::Blend { .. })
|
||||
}
|
||||
|
||||
/// A pattern's or mirror's copy of a face (a piece of one too).
|
||||
pub fn is_copy(&self) -> bool {
|
||||
self.suffixes.iter().any(|s| matches!(s, Suffix::Copy { .. }))
|
||||
}
|
||||
|
||||
/// A piece told apart by a number: an older design's numbered piece, or a tie.
|
||||
pub fn is_numbered_piece(&self) -> bool {
|
||||
self.suffixes.iter().any(|s| matches!(s, Suffix::Numbered(_) | Suffix::Piece(Qualifier { tie: Some(_), .. })))
|
||||
}
|
||||
|
||||
/// The name with each feature id mapped by `f` (given where it sits) and each body face's
|
||||
/// body by `body`; `None` when `f` refuses one. A role's text is left as written.
|
||||
pub fn map(&self, f: &mut impl FnMut(FeatureId, Place) -> Option<FeatureId>, body: &mut impl FnMut(&str) -> Option<String>) -> Option<FaceName> {
|
||||
self.map_in(Place::Name, f, body)
|
||||
}
|
||||
|
||||
fn map_in(
|
||||
&self,
|
||||
place: Place,
|
||||
f: &mut impl FnMut(FeatureId, Place) -> Option<FeatureId>,
|
||||
body: &mut impl FnMut(&str) -> Option<String>,
|
||||
) -> Option<FaceName> {
|
||||
let base = match &self.base {
|
||||
Base::Made { feature, role } => Base::Made { feature: f(*feature, place)?, role: role.clone() },
|
||||
Base::Blend { feature, rounds } => {
|
||||
let rounds = match rounds {
|
||||
Rounds::Edge(e) => Rounds::Edge(Box::new(e.map_in(place, f, body)?)),
|
||||
Rounds::Index(k) => Rounds::Index(*k),
|
||||
};
|
||||
Base::Blend { feature: f(*feature, place)?, rounds }
|
||||
}
|
||||
Base::Inner { feature, offsets } => {
|
||||
let offsets = Box::new(offsets.map_in(place, f, body)?);
|
||||
Base::Inner { feature: f(*feature, place)?, offsets }
|
||||
}
|
||||
Base::BodyFace { body: b, index } => Base::BodyFace { body: body(b).unwrap_or_else(|| b.clone()), index: *index },
|
||||
Base::Other(t) => Base::Other(t.clone()),
|
||||
};
|
||||
let mut suffixes = Vec::with_capacity(self.suffixes.len());
|
||||
for s in &self.suffixes {
|
||||
suffixes.push(match s {
|
||||
Suffix::Copy { feature, instance } => Suffix::Copy { feature: f(*feature, place)?, instance: *instance },
|
||||
Suffix::Piece(q) => {
|
||||
let around = q.around.iter().map(|a| a.map_in(Place::Qualifier, f, body)).collect::<Option<Vec<_>>>()?;
|
||||
Suffix::Piece(Qualifier { around, tie: q.tie })
|
||||
}
|
||||
Suffix::Numbered(k) => Suffix::Numbered(*k),
|
||||
});
|
||||
}
|
||||
Some(FaceName { base, suffixes })
|
||||
}
|
||||
}
|
||||
|
||||
impl EdgeName {
|
||||
fn map_in(
|
||||
&self,
|
||||
place: Place,
|
||||
f: &mut impl FnMut(FeatureId, Place) -> Option<FeatureId>,
|
||||
body: &mut impl FnMut(&str) -> Option<String>,
|
||||
) -> Option<EdgeName> {
|
||||
let faces = self.faces.iter().map(|x| x.map_in(place, f, body)).collect::<Option<Vec<_>>>()?;
|
||||
Some(EdgeName { faces, tie: self.tie })
|
||||
}
|
||||
}
|
||||
|
||||
mod lexer;
|
||||
mod parser;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod text;
|
||||
mod write;
|
||||
|
||||
pub use text::*;
|
||||
@@ -0,0 +1,93 @@
|
||||
//! The lexer: a name's text as tokens.
|
||||
|
||||
/// A plain decimal number: digits only, no leading zero (so it writes back the same).
|
||||
pub(super) fn number<T: std::str::FromStr>(d: &str) -> Option<T> {
|
||||
let ok = !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()) && (d.len() == 1 || !d.starts_with('0'));
|
||||
if ok { d.parse().ok() } else { None }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Kind {
|
||||
/// `F<n>` before a `:`.
|
||||
Feature(u64),
|
||||
/// Text between separators.
|
||||
Word,
|
||||
Colon,
|
||||
Pipe,
|
||||
Comma,
|
||||
/// `#[`
|
||||
Open,
|
||||
/// `]`
|
||||
Close,
|
||||
/// `#<n>`
|
||||
Hash(u32),
|
||||
/// `~<n>`
|
||||
Tie(u32),
|
||||
/// `@F<n>.<k>`
|
||||
Copy(u64, u32),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Tok {
|
||||
pub(super) kind: Kind,
|
||||
pub(super) start: usize,
|
||||
pub(super) end: usize,
|
||||
}
|
||||
|
||||
/// The digits of `s` from byte `i` as a number, and where they end.
|
||||
fn digits_at<T: std::str::FromStr>(s: &str, i: usize) -> Option<(T, usize)> {
|
||||
let rest = s.get(i..)?;
|
||||
let n = rest.bytes().take_while(u8::is_ascii_digit).count();
|
||||
Some((number(rest.get(..n)?)?, i + n))
|
||||
}
|
||||
|
||||
/// The separator token starting at byte `i`, if one does.
|
||||
fn separator_at(s: &str, i: usize) -> Option<Tok> {
|
||||
let b = s.as_bytes();
|
||||
let tok = |kind, end| Some(Tok { kind, start: i, end });
|
||||
match b.get(i)? {
|
||||
b':' => tok(Kind::Colon, i + 1),
|
||||
b'|' => tok(Kind::Pipe, i + 1),
|
||||
b',' => tok(Kind::Comma, i + 1),
|
||||
b']' => tok(Kind::Close, i + 1),
|
||||
b'#' if b.get(i + 1) == Some(&b'[') => tok(Kind::Open, i + 2),
|
||||
b'#' => digits_at(s, i + 1).and_then(|(k, end)| tok(Kind::Hash(k), end)),
|
||||
b'~' => digits_at(s, i + 1).and_then(|(k, end)| tok(Kind::Tie(k), end)),
|
||||
b'@' if b.get(i + 1) == Some(&b'F') => {
|
||||
let (feature, dot) = digits_at(s, i + 2)?;
|
||||
if b.get(dot) != Some(&b'.') {
|
||||
return None;
|
||||
}
|
||||
let (instance, end) = digits_at(s, dot + 1)?;
|
||||
tok(Kind::Copy(feature, instance), end)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn lex(s: &str) -> Vec<Tok> {
|
||||
let mut toks = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < s.len() {
|
||||
if let Some(t) = separator_at(s, i) {
|
||||
toks.push(t);
|
||||
i = t.end;
|
||||
continue;
|
||||
}
|
||||
// `F<n>` right before a `:` names a feature.
|
||||
if s.as_bytes().get(i) == Some(&b'F')
|
||||
&& let Some((n, end)) = digits_at::<u64>(s, i + 1)
|
||||
&& s.as_bytes().get(end) == Some(&b':')
|
||||
{
|
||||
toks.push(Tok { kind: Kind::Feature(n), start: i, end });
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < s.len() && separator_at(s, i).is_none() {
|
||||
i += s.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8);
|
||||
}
|
||||
toks.push(Tok { kind: Kind::Word, start, end: i });
|
||||
}
|
||||
toks
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! The parser: the grammar in the module's doc, by recursive descent.
|
||||
|
||||
use super::lexer::*;
|
||||
use super::*;
|
||||
|
||||
impl FaceName {
|
||||
pub fn parse(s: &str) -> Result<FaceName, NameError> {
|
||||
let mut p = Parser::new(s)?;
|
||||
let f = p.face(0)?;
|
||||
p.end()?;
|
||||
Ok(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl EdgeName {
|
||||
pub fn parse(s: &str) -> Result<EdgeName, NameError> {
|
||||
let mut p = Parser::new(s)?;
|
||||
// A `~k` ending the text numbers this edge.
|
||||
let tie = match p.toks.last() {
|
||||
Some(Tok { kind: Kind::Tie(k), .. }) => {
|
||||
let k = *k;
|
||||
p.toks.pop();
|
||||
Some(k)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let e = p.edge(0, usize::MAX)?;
|
||||
p.end()?;
|
||||
Ok(EdgeName { tie, ..e })
|
||||
}
|
||||
}
|
||||
|
||||
impl Role {
|
||||
fn from_text(t: &str) -> Role {
|
||||
match t {
|
||||
"top" => return Role::Top,
|
||||
"bottom" => return Role::Bottom,
|
||||
"start" => return Role::Start,
|
||||
"end" => return Role::End,
|
||||
"base" => return Role::Base,
|
||||
"section" => return Role::Section,
|
||||
"side" => return Role::Wall,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(rest) = t.strip_prefix("side:").filter(|r| !r.is_empty()) {
|
||||
let profiled =
|
||||
rest.strip_prefix('s').and_then(|r| r.split_once('.')).and_then(|(k, c)| Some((number(k)?, c))).filter(|(_, c)| !c.is_empty());
|
||||
return match profiled {
|
||||
Some((k, c)) => Role::Side { profile: Some(k), curve: c.to_string() },
|
||||
None => Role::Side { profile: None, curve: rest.to_string() },
|
||||
};
|
||||
}
|
||||
if let Some(k) = t.strip_prefix("face:").and_then(number) {
|
||||
return Role::Index(k);
|
||||
}
|
||||
let mut c = t.chars();
|
||||
let box_face = match (c.next(), c.next(), c.next()) {
|
||||
(Some(sign @ ('+' | '-')), Some(a), None) => match a {
|
||||
'x' => Some(Axis::X),
|
||||
'y' => Some(Axis::Y),
|
||||
'z' => Some(Axis::Z),
|
||||
_ => None,
|
||||
}
|
||||
.map(|axis| Role::BoxFace { axis, high: sign == '+' }),
|
||||
_ => None,
|
||||
};
|
||||
box_face.unwrap_or_else(|| Role::Other(t.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The trailing suffixes of `inner`, the last name nested in a face of `feature`, that name a
|
||||
/// later feature, taken off it: they are the outer face's (a later cut split it, a later pattern
|
||||
/// copied it).
|
||||
fn later_suffixes(feature: FeatureId, inner: &mut FaceName) -> Vec<Suffix> {
|
||||
let later = |s: &Suffix| match s {
|
||||
Suffix::Copy { feature: f, .. } => *f > feature,
|
||||
Suffix::Piece(q) => q.around.iter().filter_map(FaceName::feature).max().is_some_and(|f| f > feature),
|
||||
Suffix::Numbered(_) => false,
|
||||
};
|
||||
let keep = inner.suffixes.iter().rposition(|s| !later(s)).map_or(0, |i| i + 1);
|
||||
inner.suffixes.split_off(keep)
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
s: &'a str,
|
||||
toks: Vec<Tok>,
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn new(s: &'a str) -> Result<Parser<'a>, NameError> {
|
||||
if s.len() > MAX_NAME_LEN {
|
||||
return Err(NameError::TooLong);
|
||||
}
|
||||
Ok(Parser { s, toks: lex(s), at: 0 })
|
||||
}
|
||||
|
||||
fn peek(&self, ahead: usize) -> Option<Tok> {
|
||||
self.toks.get(self.at + ahead).copied()
|
||||
}
|
||||
|
||||
fn byte(&self) -> usize {
|
||||
self.peek(0).map_or(self.s.len(), |t| t.start)
|
||||
}
|
||||
|
||||
fn expected<T>(&self, what: &'static str) -> Result<T, NameError> {
|
||||
Err(NameError::Expected { at: self.byte(), what })
|
||||
}
|
||||
|
||||
fn end(&self) -> Result<(), NameError> {
|
||||
if self.peek(0).is_some() { self.expected("the end of the name") } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn word(&self, ahead: usize) -> Option<&'a str> {
|
||||
self.peek(ahead).filter(|t| t.kind == Kind::Word).and_then(|t| self.s.get(t.start..t.end))
|
||||
}
|
||||
|
||||
/// `<word>:` at the cursor.
|
||||
fn keyword(&self, w: &str) -> bool {
|
||||
self.word(0) == Some(w) && self.peek(1).is_some_and(|t| t.kind == Kind::Colon)
|
||||
}
|
||||
|
||||
/// An edge: its faces joined by `|`, at most `max` of them.
|
||||
fn edge(&mut self, depth: usize, max: usize) -> Result<EdgeName, NameError> {
|
||||
let mut faces = vec![self.face(depth + 1)?];
|
||||
while faces.len() < max && self.peek(0).map(|t| t.kind) == Some(Kind::Pipe) {
|
||||
self.at += 1;
|
||||
faces.push(self.face(depth + 1)?);
|
||||
}
|
||||
let tie = match self.peek(0).map(|t| t.kind) {
|
||||
Some(Kind::Tie(k)) => {
|
||||
self.at += 1;
|
||||
Some(k)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Ok(EdgeName { faces, tie })
|
||||
}
|
||||
|
||||
fn face(&mut self, depth: usize) -> Result<FaceName, NameError> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(NameError::TooDeep);
|
||||
}
|
||||
let mut face = match (self.peek(0).map(|t| t.kind), self.peek(1).map(|t| t.kind)) {
|
||||
(Some(Kind::Feature(n)), Some(Kind::Colon)) => {
|
||||
self.at += 2;
|
||||
let feature = FeatureId(n);
|
||||
if self.keyword("blend") {
|
||||
self.at += 2;
|
||||
self.blend(feature, depth)?
|
||||
} else if self.keyword("inner") {
|
||||
self.at += 2;
|
||||
let mut offsets = self.face(depth + 1)?;
|
||||
let moved = later_suffixes(feature, &mut offsets);
|
||||
FaceName { base: Base::Inner { feature, offsets: Box::new(offsets) }, suffixes: moved }
|
||||
} else {
|
||||
let role = Role::from_text(self.text()?);
|
||||
FaceName { base: Base::Made { feature, role }, suffixes: Vec::new() }
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let t = self.text()?;
|
||||
let body_face = t.rsplit_once(":face:").and_then(|(b, k)| Some((b, number(k)?))).filter(|(b, _)| !b.is_empty());
|
||||
let base = match body_face {
|
||||
Some((body, index)) => Base::BodyFace { body: body.to_string(), index },
|
||||
None => Base::Other(t.to_string()),
|
||||
};
|
||||
FaceName { base, suffixes: Vec::new() }
|
||||
}
|
||||
};
|
||||
self.suffixes(&mut face, depth)?;
|
||||
Ok(face)
|
||||
}
|
||||
|
||||
/// A blend face of `feature`, the cursor after `blend:`.
|
||||
fn blend(&mut self, feature: FeatureId, depth: usize) -> Result<FaceName, NameError> {
|
||||
let ends_face = |k: Option<Kind>| !matches!(k, Some(Kind::Word | Kind::Colon | Kind::Feature(_)));
|
||||
if let Some(k) = self.word(0).and_then(number::<u32>)
|
||||
&& ends_face(self.peek(1).map(|t| t.kind))
|
||||
{
|
||||
self.at += 1;
|
||||
return Ok(FaceName { base: Base::Blend { feature, rounds: Rounds::Index(k) }, suffixes: Vec::new() });
|
||||
}
|
||||
// A blend names the edge it rounds by its faces, two at most: the rest is the outer edge's.
|
||||
let mut edge = self.edge(depth + 1, 2)?;
|
||||
let moved = edge.faces.last_mut().map(|b| later_suffixes(feature, b)).unwrap_or_default();
|
||||
Ok(FaceName { base: Base::Blend { feature, rounds: Rounds::Edge(Box::new(edge)) }, suffixes: moved })
|
||||
}
|
||||
|
||||
/// Words and colons up to the next other token, as written.
|
||||
fn text(&mut self) -> Result<&'a str, NameError> {
|
||||
let start = self.byte();
|
||||
let mut end = start;
|
||||
while let Some(t) = self.peek(0).filter(|t| matches!(t.kind, Kind::Word | Kind::Colon | Kind::Feature(_))) {
|
||||
end = t.end;
|
||||
self.at += 1;
|
||||
}
|
||||
match self.s.get(start..end).filter(|t| !t.is_empty()) {
|
||||
Some(t) => Ok(t),
|
||||
None => self.expected("a face"),
|
||||
}
|
||||
}
|
||||
|
||||
fn suffixes(&mut self, face: &mut FaceName, depth: usize) -> Result<(), NameError> {
|
||||
while let Some(t) = self.peek(0) {
|
||||
match t.kind {
|
||||
Kind::Copy(feature, instance) => {
|
||||
self.at += 1;
|
||||
face.suffixes.push(Suffix::Copy { feature: FeatureId(feature), instance });
|
||||
}
|
||||
Kind::Open => {
|
||||
self.at += 1;
|
||||
let mut around = vec![self.face(depth + 1)?];
|
||||
loop {
|
||||
match self.peek(0).map(|t| t.kind) {
|
||||
Some(Kind::Comma) => {
|
||||
self.at += 1;
|
||||
around.push(self.face(depth + 1)?);
|
||||
}
|
||||
Some(Kind::Close) => {
|
||||
self.at += 1;
|
||||
break;
|
||||
}
|
||||
_ => return self.expected("`,` or `]` in a piece's faces"),
|
||||
}
|
||||
}
|
||||
let tie = match self.peek(0).map(|t| t.kind) {
|
||||
Some(Kind::Hash(k)) => {
|
||||
self.at += 1;
|
||||
Some(k)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
face.suffixes.push(Suffix::Piece(Qualifier { around, tie }));
|
||||
}
|
||||
Kind::Hash(k) => {
|
||||
self.at += 1;
|
||||
face.suffixes.push(Suffix::Numbered(k));
|
||||
}
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
use super::*;
|
||||
|
||||
fn face(s: &str) -> FaceName {
|
||||
let f = FaceName::parse(s).unwrap_or_else(|e| panic!("{s}: {e}"));
|
||||
assert_eq!(f.to_string(), s, "writes back");
|
||||
f
|
||||
}
|
||||
|
||||
fn edge(s: &str) -> EdgeName {
|
||||
let e = EdgeName::parse(s).unwrap_or_else(|e| panic!("{s}: {e}"));
|
||||
assert_eq!(e.to_string(), s, "writes back");
|
||||
e
|
||||
}
|
||||
|
||||
fn made(feature: u64, role: Role) -> FaceName {
|
||||
FaceName { base: Base::Made { feature: FeatureId(feature), role }, suffixes: Vec::new() }
|
||||
}
|
||||
|
||||
fn other(role: &str) -> Role {
|
||||
Role::Other(role.into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roles_are_typed_and_the_rest_kept_as_written() {
|
||||
assert_eq!(face("F2:top"), made(2, Role::Top));
|
||||
assert_eq!(face("F4:side#1").stem(), made(4, Role::Wall));
|
||||
assert_eq!(face("F2:side:l4"), made(2, Role::Side { profile: None, curve: "l4".into() }));
|
||||
assert_eq!(face("F2:side:s1.l4"), made(2, Role::Side { profile: Some(1), curve: "l4".into() }));
|
||||
assert_eq!(face("F3:+x"), made(3, Role::BoxFace { axis: Axis::X, high: true }));
|
||||
assert_eq!(face("F3:-z"), made(3, Role::BoxFace { axis: Axis::Z, high: false }));
|
||||
assert_eq!(face("F2:face:7"), made(2, Role::Index(7)));
|
||||
assert_eq!(face("F9:cap:high:0"), made(9, other("cap:high:0")));
|
||||
assert_eq!(face("F12:boss:side:3"), made(12, other("boss:side:3")));
|
||||
assert_eq!(face("Body1:face:3").base, Base::BodyFace { body: "Body1".into(), index: 3 });
|
||||
assert_eq!(face("Plate:rim.end").base, Base::Other("Plate:rim.end".into()));
|
||||
// Numbers that would not write back the same stay text.
|
||||
assert_eq!(face("F2:side:s01.l4"), made(2, Role::Side { profile: None, curve: "s01.l4".into() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_copies_and_numbered_pieces() {
|
||||
let p = face("F2:top#[F2:side:l2,F4:side:l2]");
|
||||
let Some(Suffix::Piece(q)) = p.suffixes.first() else { panic!("{p:?}") };
|
||||
assert_eq!(q.around().len(), 2);
|
||||
assert_eq!(p.stem(), made(2, Role::Top));
|
||||
assert!(matches!(face("F2:top#[F4:side:l2]#1").suffixes.as_slice(), [Suffix::Piece(Qualifier { tie: Some(1), .. })]));
|
||||
assert_eq!(face("F2:top#2").suffixes, vec![Suffix::Numbered(2)]);
|
||||
let c = face("F2:side:l1@F7.2");
|
||||
assert_eq!(c.suffixes, vec![Suffix::Copy { feature: FeatureId(7), instance: 2 }]);
|
||||
assert_eq!(c.feature(), Some(FeatureId(7)));
|
||||
assert_eq!(face("F2:side:l1@F7.2#[F8:x]").stem(), c);
|
||||
assert!(Qualifier::new(Vec::new(), None).is_none());
|
||||
}
|
||||
|
||||
/// The fillet rounds an edge of a piece the slot made before it: the qualifier is the
|
||||
/// piece's (corpus `naming-split-pieces`).
|
||||
#[test]
|
||||
fn a_blend_of_an_earlier_piece_keeps_the_qualifier_inside() {
|
||||
let f = face("F5:blend:F2:side:l2|F2:top#[F2:side:l2,F4:side:l2]");
|
||||
let Base::Blend { feature: FeatureId(5), rounds: Rounds::Edge(e) } = &f.base else { panic!("{f:?}") };
|
||||
assert!(f.suffixes.is_empty());
|
||||
assert_eq!(e.faces[1].stem(), made(2, Role::Top));
|
||||
assert_eq!(e.faces[1].suffixes.len(), 1);
|
||||
}
|
||||
|
||||
/// A slot cut after the fillet splits the fillet face: the qualifier, naming the later slot,
|
||||
/// is the fillet face's.
|
||||
#[test]
|
||||
fn a_blend_split_by_a_later_cut_takes_the_qualifier() {
|
||||
let f = face("F4:blend:F3:+x|F3:+z#[F3:-y,F5:-y]");
|
||||
let Base::Blend { feature: FeatureId(4), rounds: Rounds::Edge(e) } = &f.base else { panic!("{f:?}") };
|
||||
assert_eq!(e.faces[1], made(3, Role::BoxFace { axis: Axis::Z, high: true }));
|
||||
assert!(matches!(f.suffixes.as_slice(), [Suffix::Piece(_)]));
|
||||
// Copied by a later pattern, then split: both are the blend face's, in order.
|
||||
let g = face("F4:blend:F3:+x|F3:+z@F6.1#[F7:a]");
|
||||
assert!(matches!(g.suffixes.as_slice(), [Suffix::Copy { feature: FeatureId(6), .. }, Suffix::Piece(_)]));
|
||||
assert_eq!(g.feature(), Some(FeatureId(6)));
|
||||
}
|
||||
|
||||
/// What the text cannot tell: a later cut told apart only by older faces reads as a piece of
|
||||
/// the inner face.
|
||||
#[test]
|
||||
fn a_later_cut_told_apart_by_older_faces_reads_as_the_inner_faces() {
|
||||
let f = face("F4:blend:F3:+x|F3:+z#[F3:-y]");
|
||||
let Base::Blend { rounds: Rounds::Edge(e), .. } = &f.base else { panic!("{f:?}") };
|
||||
assert!(f.suffixes.is_empty() && e.faces[1].suffixes.len() == 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_split_into_their_two_faces() {
|
||||
let e = edge("F2:side:l1|F2:side:l2");
|
||||
assert_eq!(e.faces[0], made(2, Role::Side { profile: None, curve: "l1".into() }));
|
||||
assert_eq!(edge("F2:side:l1|F2:top~1").tie, Some(1));
|
||||
// The blend face first: the whole blend name, then the slot's wall.
|
||||
let e = edge("F4:blend:F3:+x|F3:+z#[F3:-y,F5:-y]|F5:-y");
|
||||
assert_eq!(e.faces[0].to_string(), "F4:blend:F3:+x|F3:+z#[F3:-y,F5:-y]");
|
||||
assert_eq!(e.faces[1], made(5, Role::BoxFace { axis: Axis::Y, high: false }));
|
||||
// The blend face second.
|
||||
let e = edge("F2:side:l1|F5:blend:F2:side:l2|F2:top#[F2:side:l2,F4:side:l2]");
|
||||
assert!(matches!(e.faces[1].base, Base::Blend { feature: FeatureId(5), .. }));
|
||||
// A blend of a blend's edge.
|
||||
let e = edge("F6:blend:F4:blend:a|b|c|d");
|
||||
let Base::Blend { rounds: Rounds::Edge(inner), .. } = &e.faces[0].base else { panic!("{e:?}") };
|
||||
assert!(matches!(inner.faces[0].base, Base::Blend { feature: FeatureId(4), .. }));
|
||||
assert_eq!(e.faces[1].base, Base::Other("d".into()));
|
||||
// A `~k` ending the edge numbers it, not the blend's edge.
|
||||
let e = edge("F2:x|F5:blend:a|b~1");
|
||||
assert_eq!(e.tie, Some(1));
|
||||
assert!(matches!(&e.faces[1].base, Base::Blend { rounds: Rounds::Edge(b), .. } if b.tie.is_none()));
|
||||
// A blend named by its edge's index.
|
||||
assert_eq!(edge("F5:blend:3|F2:top").faces[0].base, Base::Blend { feature: FeatureId(5), rounds: Rounds::Index(3) });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_walls_nest_a_face() {
|
||||
let f = face("F3:inner:F2:top#[F2:side:l2]");
|
||||
let Base::Inner { feature: FeatureId(3), offsets } = &f.base else { panic!("{f:?}") };
|
||||
assert_eq!(offsets.stem(), made(2, Role::Top));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_made_a_face_and_its_kind() {
|
||||
assert_eq!(made_by("F2:side:l1@F7.2"), Some(2));
|
||||
assert_eq!(made_by("F3:inner:F5:blend:a|b"), Some(3));
|
||||
assert_eq!(made_by("Body1:face:3"), None);
|
||||
assert_eq!(split_feature("F12:boss:side:3"), Some((12, "boss:side:3")));
|
||||
// A blend named only inside another name is not one.
|
||||
assert!(is_blend("F5:blend:F2:x|F2:y#[F6:z]"));
|
||||
assert!(!is_blend("F2:top#[F5:blend:a|b]"));
|
||||
assert!(!is_blend("F3:inner:F5:blend:a|b"));
|
||||
// A copy only inside a blend's edge doesn't make the blend face a copy.
|
||||
assert!(is_copy("F2:side:l1@F7.2#[F8:x]"));
|
||||
assert!(!is_copy("F5:blend:F2:x@F3.1|F2:y"));
|
||||
assert!(is_numbered_piece("F2:top#2") && is_numbered_piece("F2:top#[F4:x]#1"));
|
||||
assert!(!is_numbered_piece("F5:blend:F2:top#2|F2:x") && !is_numbered_piece("F2:top#[F4:x]"));
|
||||
assert!(is_wall("F4:side") && !is_wall("F4:side#1") && !is_wall("F5:blend:F2:x|F4:side"));
|
||||
}
|
||||
|
||||
/// A shell's inner wall split by a later cut: the qualifier, naming the cut, is the wall's.
|
||||
#[test]
|
||||
fn an_inner_wall_split_by_a_later_cut_takes_the_qualifier() {
|
||||
let f = face("F3:inner:F2:top#[F2:side:l2,F6:x]");
|
||||
assert!(matches!(f.suffixes.as_slice(), [Suffix::Piece(_)]));
|
||||
assert_eq!(f.stem().to_string(), "F3:inner:F2:top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_in_text_order() {
|
||||
let ids = |s: &str| face(s).features().into_iter().map(|f| f.0).collect::<Vec<_>>();
|
||||
assert_eq!(ids("F3:inner:F2:blend:F1:+y|F1:-x"), vec![3, 2, 1, 1]);
|
||||
assert_eq!(ids("F82:-z@F84.2"), vec![82, 84]);
|
||||
assert_eq!(ids("F2:top#[F2:side:l2,F4:side:l2]"), vec![2, 2, 4]);
|
||||
assert_eq!(ids("F1:side:曲線F10"), vec![1]);
|
||||
assert!(ids("Body1:face:3").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_ids_map_everywhere_but_role_text() {
|
||||
let ids = std::collections::BTreeMap::from([(2, 12), (5, 15)]);
|
||||
let mapped = map_features("F5:blend:F2:side:l2|F2:top#[F2:side:l2,F4:side:l2]@F2.1", &ids);
|
||||
assert_eq!(mapped.as_deref(), Some("F15:blend:F12:side:l2|F12:top#[F12:side:l2,F4:side:l2]@F12.1"));
|
||||
// Where an id sits: a name's ids must map, a qualifier's may stay.
|
||||
let f = face("F2:top#[F9:x]");
|
||||
let strict = f.map(
|
||||
&mut |id, place| match place {
|
||||
Place::Name => ids.get(&id.0).map(|n| FeatureId(*n)),
|
||||
Place::Qualifier => Some(ids.get(&id.0).map_or(id, |n| FeatureId(*n))),
|
||||
},
|
||||
&mut |_| None,
|
||||
);
|
||||
assert_eq!(strict.map(|f| f.to_string()).as_deref(), Some("F12:top#[F9:x]"));
|
||||
assert!(face("F9:top").map(&mut |id, _| ids.get(&id.0).map(|n| FeatureId(*n)), &mut |_| None).is_none());
|
||||
let body = face("Body1:face:3").map(&mut |id, _| Some(id), &mut |b| (b == "Body1").then(|| "Body2".to_string()));
|
||||
assert_eq!(body.map(|f| f.to_string()).as_deref(), Some("Body2:face:3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_say_where() {
|
||||
assert_eq!(EdgeName::parse("F2:top|"), Err(NameError::Expected { at: 7, what: "a face" }));
|
||||
assert_eq!(FaceName::parse("F2:top#[F3:x"), Err(NameError::Expected { at: 12, what: "`,` or `]` in a piece's faces" }));
|
||||
assert_eq!(FaceName::parse("a|b"), Err(NameError::Expected { at: 1, what: "the end of the name" }));
|
||||
assert_eq!(FaceName::parse(""), Err(NameError::Expected { at: 0, what: "a face" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_names_never_panic() {
|
||||
let long = "x".repeat(MAX_NAME_LEN + 1);
|
||||
let deep = format!("{}x", "F1:inner:".repeat(64));
|
||||
let deep_pieces = format!("x{}{}", "#[x".repeat(64), "]".repeat(64));
|
||||
for s in [
|
||||
"",
|
||||
"|",
|
||||
"||",
|
||||
"#[",
|
||||
"#[]",
|
||||
"x#[",
|
||||
"x#[]",
|
||||
"x#[y",
|
||||
"x#[y,",
|
||||
"]",
|
||||
"x]",
|
||||
"F1:blend:",
|
||||
"F1:blend:x",
|
||||
"F1:blend:x|",
|
||||
"@F",
|
||||
"x@F1.",
|
||||
"x@F",
|
||||
"x~",
|
||||
"x|y~",
|
||||
"x|y~01",
|
||||
"F01:top",
|
||||
"x#0x",
|
||||
"F99999999999999999999999:top",
|
||||
"x#99999999999",
|
||||
"\u{1F600}|\u{1F601}",
|
||||
long.as_str(),
|
||||
deep.as_str(),
|
||||
deep_pieces.as_str(),
|
||||
] {
|
||||
if let Ok(f) = FaceName::parse(s) {
|
||||
assert_eq!(f.to_string(), s);
|
||||
}
|
||||
if let Ok(e) = EdgeName::parse(s) {
|
||||
assert_eq!(e.to_string(), s);
|
||||
}
|
||||
}
|
||||
assert_eq!(FaceName::parse(&long), Err(NameError::TooLong));
|
||||
assert_eq!(FaceName::parse(&deep), Err(NameError::TooDeep));
|
||||
assert_eq!(FaceName::parse(&deep_pieces), Err(NameError::TooDeep));
|
||||
// An open boundary's edge has one face; an edge three faces meet at, three.
|
||||
assert_eq!(edge("F2:top").faces.len(), 1);
|
||||
assert_eq!(edge("x|y|z").faces.len(), 3);
|
||||
// A blend's edge has two faces at most; a third is the outer edge's.
|
||||
let e = edge("F5:blend:a|b|c");
|
||||
assert!(matches!(&e.faces[0].base, Base::Blend { rounds: Rounds::Edge(inner), .. } if inner.faces.len() == 2) && e.faces.len() == 2);
|
||||
// A fillet on an open boundary's edge.
|
||||
assert!(matches!(face("F1:blend:F10:start").base, Base::Blend { rounds: Rounds::Edge(ref e), .. } if e.faces.len() == 1));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Questions callers ask of a name kept as a `String`.
|
||||
|
||||
use super::lexer::number;
|
||||
use super::*;
|
||||
|
||||
/// The feature whose `F<n>:` starts the face name `name` (a copy's is its source's). Every name a
|
||||
/// feature made starts so (`F2:top`, `F5:blend:…`, `F3:inner:…`), so only that token is read:
|
||||
/// cheap enough to ask of every face each frame.
|
||||
pub fn made_by(name: &str) -> Option<u64> {
|
||||
let rest = name.strip_prefix('F')?;
|
||||
let end = rest.find(|c: char| !c.is_ascii_digit())?;
|
||||
if rest.get(end..end + 1) != Some(":") {
|
||||
return None;
|
||||
}
|
||||
number(rest.get(..end)?)
|
||||
}
|
||||
|
||||
/// `name` without the `F<n>:` of the feature that made it, and `n`.
|
||||
pub fn split_feature(name: &str) -> Option<(u64, &str)> {
|
||||
let id = made_by(name)?;
|
||||
Some((id, name.strip_prefix(&format!("F{id}:"))?))
|
||||
}
|
||||
|
||||
/// Whether `name` is a pattern's or mirror's copy of a face.
|
||||
pub fn is_copy(name: &str) -> bool {
|
||||
FaceName::parse(name).map_or_else(|_| name.contains('@'), |f| f.is_copy())
|
||||
}
|
||||
|
||||
/// Whether `name` is a fillet's or chamfer's face.
|
||||
pub fn is_blend(name: &str) -> bool {
|
||||
FaceName::parse(name).map_or_else(|_| name.contains(":blend:"), |f| f.is_blend())
|
||||
}
|
||||
|
||||
/// Whether `name` is a piece told apart by a number (an older design's piece, or a tie).
|
||||
pub fn is_numbered_piece(name: &str) -> bool {
|
||||
FaceName::parse(name).map_or_else(
|
||||
|_| name.match_indices('#').any(|(i, _)| name.get(i + 1..).is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))),
|
||||
|f| f.is_numbered_piece(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `name` is a whole `side` face: a cylinder's wall, or a tool's faces swept from its
|
||||
/// whole profile.
|
||||
pub fn is_wall(name: &str) -> bool {
|
||||
FaceName::parse(name).map_or_else(|_| name.ends_with(":side"), |f| f.suffixes.is_empty() && matches!(f.base, Base::Made { role: Role::Wall, .. }))
|
||||
}
|
||||
|
||||
/// `name` with every feature id `ids` maps replaced, the rest kept; `None` when it doesn't read.
|
||||
pub fn map_features(name: &str, ids: &std::collections::BTreeMap<u64, u64>) -> Option<String> {
|
||||
let f = FaceName::parse(name).ok()?;
|
||||
let mapped = f.map(&mut |id, _| Some(ids.get(&id.0).map_or(id, |n| FeatureId(*n))), &mut |_| None)?;
|
||||
Some(mapped.to_string())
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Writing names back as text: `Display`.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl fmt::Display for Role {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Role::Top => f.write_str("top"),
|
||||
Role::Bottom => f.write_str("bottom"),
|
||||
Role::Start => f.write_str("start"),
|
||||
Role::End => f.write_str("end"),
|
||||
Role::Base => f.write_str("base"),
|
||||
Role::Section => f.write_str("section"),
|
||||
Role::Wall => f.write_str("side"),
|
||||
Role::Side { profile: Some(k), curve } => write!(f, "side:s{k}.{curve}"),
|
||||
Role::Side { profile: None, curve } => write!(f, "side:{curve}"),
|
||||
Role::BoxFace { axis, high } => {
|
||||
let a = match axis {
|
||||
Axis::X => 'x',
|
||||
Axis::Y => 'y',
|
||||
Axis::Z => 'z',
|
||||
};
|
||||
write!(f, "{}{a}", if *high { '+' } else { '-' })
|
||||
}
|
||||
Role::Index(k) => write!(f, "face:{k}"),
|
||||
Role::Other(t) => f.write_str(t),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FaceName {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match &self.base {
|
||||
Base::Made { feature, role } => write!(f, "F{}:{role}", feature.0)?,
|
||||
Base::Blend { feature, rounds: Rounds::Edge(e) } => write!(f, "F{}:blend:{e}", feature.0)?,
|
||||
Base::Blend { feature, rounds: Rounds::Index(k) } => write!(f, "F{}:blend:{k}", feature.0)?,
|
||||
Base::Inner { feature, offsets } => write!(f, "F{}:inner:{offsets}", feature.0)?,
|
||||
Base::BodyFace { body, index } => write!(f, "{body}:face:{index}")?,
|
||||
Base::Other(t) => f.write_str(t)?,
|
||||
}
|
||||
for s in &self.suffixes {
|
||||
match s {
|
||||
Suffix::Copy { feature, instance } => write!(f, "@F{}.{instance}", feature.0)?,
|
||||
Suffix::Piece(q) => {
|
||||
f.write_str("#[")?;
|
||||
for (i, a) in q.around.iter().enumerate() {
|
||||
if i > 0 {
|
||||
f.write_str(",")?;
|
||||
}
|
||||
write!(f, "{a}")?;
|
||||
}
|
||||
f.write_str("]")?;
|
||||
if let Some(k) = q.tie {
|
||||
write!(f, "#{k}")?;
|
||||
}
|
||||
}
|
||||
Suffix::Numbered(k) => write!(f, "#{k}")?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EdgeName {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for (i, face) in self.faces.iter().enumerate() {
|
||||
if i > 0 {
|
||||
f.write_str("|")?;
|
||||
}
|
||||
write!(f, "{face}")?;
|
||||
}
|
||||
match self.tie {
|
||||
Some(k) => write!(f, "~{k}"),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NameError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
NameError::TooLong => write!(f, "a name is at most {MAX_NAME_LEN} bytes"),
|
||||
NameError::TooDeep => write!(f, "a name nests at most {MAX_DEPTH} deep"),
|
||||
NameError::Expected { at, what } => write!(f, "expected {what} at byte {at}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NameError {}
|
||||
@@ -87,6 +87,7 @@ impl PlacedMesh {
|
||||
positions: source.positions.iter().map(|p| crate::apply_point(&self.transform, *p)).collect(),
|
||||
normals: source.normals.iter().map(|n| crate::apply_vector(&self.transform, *n)).collect(),
|
||||
edges: source.edges.iter().map(|edge| edge.iter().map(|p| crate::apply_point(&self.transform, *p)).collect()).collect(),
|
||||
display_only_edges: source.display_only_edges,
|
||||
triangles: source.triangles.clone(),
|
||||
tri_face: source.tri_face.clone(),
|
||||
edge_faces: source.edge_faces.clone(),
|
||||
@@ -115,7 +116,51 @@ pub(super) fn definition_plan(
|
||||
}
|
||||
|
||||
pub(super) fn reference_mesh(body: &Body, mesh: &OnceLock<Arc<Mesh>>, tolerance: f64) -> Arc<Mesh> {
|
||||
mesh.get_or_init(|| Arc::new(body.reference_display_mesh_angular(tolerance, kernel::DISPLAY_ANGLE).unwrap_or_default())).clone()
|
||||
mesh.get_or_init(|| {
|
||||
let mut mesh = body.reference_display_mesh_angular(tolerance, kernel::DISPLAY_ANGLE).unwrap_or_default();
|
||||
if let Some((positions, triangles)) = body.triangle_mesh() {
|
||||
mesh.edges = mesh_outlines(positions, triangles);
|
||||
mesh.display_only_edges = true;
|
||||
}
|
||||
Arc::new(mesh)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Display lines for an imported triangle mesh: open boundaries, non-manifold sides and
|
||||
/// creases of at least 30 degrees. The shared vertex indices come from the existing mesh body;
|
||||
/// the geometry, winding and triangle topology are left to the importer.
|
||||
fn mesh_outlines(positions: &[Vec3], triangles: &[[u32; 3]]) -> Vec<Vec<Vec3>> {
|
||||
let normal = |index: usize| {
|
||||
let t = triangles.get(index)?;
|
||||
let (a, b, c) = (positions.get(t[0] as usize)?, positions.get(t[1] as usize)?, positions.get(t[2] as usize)?);
|
||||
(*b - *a).cross(*c - *a).normalized()
|
||||
};
|
||||
// One compact record per side, sorted for deterministic output, instead of a map of
|
||||
// separately allocated adjacency lists. Mesh imports already cap the triangle count.
|
||||
let mut sides = Vec::with_capacity(triangles.len().saturating_mul(3));
|
||||
for (i, t) in triangles.iter().enumerate() {
|
||||
let Ok(triangle) = u32::try_from(i) else { continue };
|
||||
if normal(i).is_some() {
|
||||
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
|
||||
sides.push((a.min(b), a.max(b), triangle));
|
||||
}
|
||||
}
|
||||
}
|
||||
sides.sort_unstable();
|
||||
let cos = 30f64.to_radians().cos();
|
||||
let mut edges = Vec::new();
|
||||
for side in sides.chunk_by(|a, b| (a.0, a.1) == (b.0, b.1)) {
|
||||
let Some(&(a, b, _)) = side.first() else { continue };
|
||||
let visible = match side {
|
||||
[(_, _, i), (_, _, j)] => normal(*i as usize).zip(normal(*j as usize)).is_some_and(|(n, m)| n.dot(m) <= cos),
|
||||
_ => true,
|
||||
};
|
||||
if visible && let (Some(&a), Some(&b)) = (positions.get(a as usize), positions.get(b as usize)) {
|
||||
edges.push(vec![a, b]);
|
||||
}
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
/// Only proper rotations and translations preserve triangle winding, normal lengths and the
|
||||
@@ -135,6 +180,45 @@ fn proper_rigid(m: &crate::Mat) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mesh_outlines_keep_boundaries_and_creases_without_smooth_facet_lines() {
|
||||
for (degrees, expected) in [(0.0f64, 4), (15.0, 4), (30.0, 5), (45.0, 5), (90.0, 5)] {
|
||||
let angle = degrees.to_radians();
|
||||
let p = [Vec3::ZERO, Vec3::X, Vec3::Y, Vec3::new(0., -angle.cos(), angle.sin())];
|
||||
let triangles = [[0, 1, 2], [1, 0, 3]];
|
||||
let edges = mesh_outlines(&p, &triangles);
|
||||
assert_eq!(edges.len(), expected, "hinge angle {degrees}");
|
||||
assert_eq!(edges.iter().any(|e| e == &[Vec3::ZERO, Vec3::X]), degrees >= 30.);
|
||||
assert_eq!(edges, mesh_outlines(&p, &triangles), "deterministic order");
|
||||
}
|
||||
// A third triangle makes the common side non-manifold: keep it once, even if coplanar.
|
||||
let p = [Vec3::ZERO, Vec3::X, Vec3::Y, -Vec3::Y, Vec3::Y * 2.];
|
||||
let edges = mesh_outlines(&p, &[[0, 1, 2], [1, 0, 3], [0, 1, 4]]);
|
||||
assert_eq!(edges.len(), 7);
|
||||
assert_eq!(edges.iter().filter(|e| **e == [Vec3::ZERO, Vec3::X]).count(), 1);
|
||||
// The rings of an open faceted cylinder stay visible, but its smooth wall has no
|
||||
// diagonals or longitudinal facet lines.
|
||||
let n = 36u32;
|
||||
let mut p = Vec::new();
|
||||
for z in [0., 10.] {
|
||||
p.extend((0..n).map(|i| {
|
||||
let a = f64::from(i) * std::f64::consts::TAU / f64::from(n);
|
||||
Vec3::new(a.cos(), a.sin(), z)
|
||||
}));
|
||||
}
|
||||
let triangles: Vec<_> = (0..n)
|
||||
.flat_map(|i| {
|
||||
let j = (i + 1) % n;
|
||||
[[i, j, j + n], [i, j + n, i + n]]
|
||||
})
|
||||
.collect();
|
||||
let edges = mesh_outlines(&p, &triangles);
|
||||
assert_eq!(edges.len(), 2 * n as usize);
|
||||
assert!(edges.iter().all(|e| e[0].z == e[1].z));
|
||||
// Bad indices and zero-area facets do not invent outlines or panic.
|
||||
assert!(mesh_outlines(&[Vec3::ZERO, Vec3::X, Vec3::X * 2.], &[[0, 1, 99], [0, 1, 2]]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_mesh_is_lazy_and_composes_from_one_immutable_source() {
|
||||
let source = ModelBody::new("Part".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(2., 3., 4.)).unwrap(), 1);
|
||||
@@ -297,6 +381,33 @@ mod tests {
|
||||
assert_ne!(new.bounds(), moved.bodies[0].mesh().bounds());
|
||||
assert!(Arc::ptr_eq(&old, &before.bodies[0].mesh()), "editing does not mutate older snapshots");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placements_taken_as_before_and_placed_again_keep_their_order() {
|
||||
let mut doc = crate::Document::new("instances");
|
||||
let component = doc.add_component(Some("Part"), 0).unwrap();
|
||||
doc.body_components.insert("Pin".into(), component);
|
||||
doc.occurrences[0].transform = crate::rigid(Vec3::new(10., 0., 0.), Vec3::ZERO, Vec3::Y, 0.3);
|
||||
let second = doc.add_occurrence(component, 0, crate::rigid(Vec3::new(0., 20., 0.), Vec3::ZERO, Vec3::X, 0.6)).unwrap();
|
||||
doc.add_occurrence(component, 0, crate::rigid(Vec3::new(0., -20., 5.), Vec3::ZERO, Vec3::Z, 1.1)).unwrap();
|
||||
let mut state = crate::ModelState::default();
|
||||
state.bodies.push(ModelBody::new("Pin".into(), kernel::cylinder(Vec3::ZERO, Vec3::Z, 2., 8.).unwrap(), 1));
|
||||
let mut cache = super::super::PlacedBodies::default();
|
||||
let before = super::super::world_state_reusing(&doc, &state, &mut cache);
|
||||
let meshes: Vec<_> = before.bodies.iter().map(|b| b.mesh()).collect();
|
||||
// The middle placement moves: the other two are taken as they were, it is placed again.
|
||||
doc.occurrences.iter_mut().find(|o| o.id == second).unwrap().transform = crate::rigid(Vec3::new(0., 40., 0.), Vec3::ZERO, Vec3::X, 0.2);
|
||||
let after = super::super::world_state_reusing(&doc, &state, &mut cache);
|
||||
let fresh = super::super::world_state_reusing(&doc, &state, &mut super::super::PlacedBodies::default());
|
||||
let names = |st: &crate::ModelState| st.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&after), names(&fresh));
|
||||
assert_eq!(names(&after), names(&before));
|
||||
for (i, (a, f)) in after.bodies.iter().zip(&fresh.bodies).enumerate() {
|
||||
assert_eq!(a.mesh().bounds(), f.mesh().bounds(), "body {i} is where a fresh placement puts it");
|
||||
}
|
||||
assert!(Arc::ptr_eq(&after.bodies[0].mesh(), &meshes[0]) && Arc::ptr_eq(&after.bodies[2].mesh(), &meshes[2]));
|
||||
assert!(!Arc::ptr_eq(&after.bodies[1].mesh(), &meshes[1]), "the moved placement is placed again");
|
||||
}
|
||||
#[test]
|
||||
fn only_proper_rigid_affine_matrices_reuse_reference_meshes() {
|
||||
let source = ModelBody::new("Part".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(2., 3., 4.)).unwrap(), 1);
|
||||
|
||||
@@ -572,6 +572,32 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelS
|
||||
refresh_links_in(doc, vals, st, plane, sk, None)
|
||||
}
|
||||
|
||||
/// Lay every text that runs along a curve of its sketch along that curve as the solve left it.
|
||||
/// The texts are laid out with the other links, before the solve, which can move their path (a
|
||||
/// path dimensioned from a parameter): without this a text followed its path one evaluation
|
||||
/// late. The warnings for texts that no longer lay out.
|
||||
pub(crate) fn relay_path_texts(sk: &mut Sketch) -> Vec<String> {
|
||||
let texts: Vec<(String, LinkSource)> = sk
|
||||
.links
|
||||
.iter()
|
||||
.filter(|l| !l.lost && matches!(&l.source, LinkSource::Text { style, .. } if style.path.is_some()))
|
||||
.map(|l| (l.id.clone(), l.source.clone()))
|
||||
.collect();
|
||||
let mut warn = Vec::new();
|
||||
for (id, source) in texts {
|
||||
match sk.text_source_geometry(&source) {
|
||||
Ok(geom) if geom.is_empty() => warn.push(format!("the {} makes no outline", source.describe())),
|
||||
Ok(geom) => {
|
||||
if let Err(e) = sk.update_link(&id, &geom) {
|
||||
warn.push(format!("{}: {e}", source.describe()));
|
||||
}
|
||||
}
|
||||
Err(e) => warn.push(format!("{}: {e}", source.describe())),
|
||||
}
|
||||
}
|
||||
warn
|
||||
}
|
||||
|
||||
fn refresh_links_in(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
|
||||
+236
-6
@@ -259,6 +259,11 @@ pub struct SheetRule {
|
||||
/// Gap between flanges that meet at a corner.
|
||||
#[serde(default = "rule_gap")]
|
||||
pub gap: String,
|
||||
/// How the bend angles of its parts read in the DXF bend notes: `bend` (how far the sheet
|
||||
/// turns from flat) or `included` (the angle left between the bend's two sides, as a press
|
||||
/// brake is set). A choice, not an expression; saved only when it isn't `bend`.
|
||||
#[serde(default = "rule_angle_display", skip_serializing_if = "is_bend_display")]
|
||||
pub angle_display: String,
|
||||
}
|
||||
|
||||
impl SheetRule {
|
||||
@@ -311,6 +316,14 @@ fn rule_corner_relief_shape() -> String {
|
||||
fn rule_hem_gap() -> String {
|
||||
"0.01 mm".into()
|
||||
}
|
||||
fn rule_angle_display() -> String {
|
||||
AngleDisplay::Bend.name().into()
|
||||
}
|
||||
/// A rule that reads bend angles as bend angles leaves the setting out of the design, which
|
||||
/// then saves as it did before the setting existed.
|
||||
fn is_bend_display(s: &str) -> bool {
|
||||
AngleDisplay::parse(s) == Some(AngleDisplay::Bend)
|
||||
}
|
||||
|
||||
impl SheetRule {
|
||||
/// Default: Steel (mm), 2.5 mm, K 0.44, bend radius = Thickness.
|
||||
@@ -326,6 +339,7 @@ impl SheetRule {
|
||||
corner_relief_shape: rule_corner_relief_shape(),
|
||||
hem_gap: rule_hem_gap(),
|
||||
gap: rule_gap(),
|
||||
angle_display: rule_angle_display(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,6 +398,48 @@ impl ReliefShape {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a rule's bend angles read in the DXF bend notes (a rule's `angle_display`). The bend
|
||||
/// table gives both angles, and a flange's `angle` is always the bend angle.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AngleDisplay {
|
||||
/// How far the sheet turns from flat: a flange at 58° reads 58°.
|
||||
#[default]
|
||||
Bend,
|
||||
/// The angle left between the bend's two sides, 180° less the bend angle: a 58° bend reads
|
||||
/// 122°, a 90° bend 90°.
|
||||
Included,
|
||||
}
|
||||
|
||||
impl AngleDisplay {
|
||||
pub fn parse(s: &str) -> Option<AngleDisplay> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"bend" => Some(AngleDisplay::Bend),
|
||||
"included" => Some(AngleDisplay::Included),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
AngleDisplay::Bend => "bend",
|
||||
AngleDisplay::Included => "included",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_bend(&self) -> bool {
|
||||
*self == AngleDisplay::Bend
|
||||
}
|
||||
|
||||
/// A bend of `bend_deg` degrees as this display reads it.
|
||||
pub fn of(self, bend_deg: f64) -> f64 {
|
||||
match self {
|
||||
AngleDisplay::Bend => bend_deg,
|
||||
AngleDisplay::Included => 180.0 - bend_deg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The corner relief an edge flange feature asks for in place of the rule's. A value left out
|
||||
/// follows the rule.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
@@ -405,6 +461,7 @@ pub struct RuleValues {
|
||||
pub corner_relief_shape: ReliefShape,
|
||||
pub hem_gap: f64,
|
||||
pub gap: f64,
|
||||
pub angle_display: AngleDisplay,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
@@ -440,6 +497,8 @@ impl Document {
|
||||
})?,
|
||||
hem_gap: with_t(&r.hem_gap, Kind::Length)?,
|
||||
gap: with_t(&r.gap, Kind::Length)?,
|
||||
angle_display: AngleDisplay::parse(&r.angle_display)
|
||||
.ok_or_else(|| DocError::Invalid(format!("unknown bend angle display `{}` (bend or included)", r.angle_display)))?,
|
||||
};
|
||||
if !(0.0..=1.0).contains(&v.k_factor) || v.bend_radius < 0.0 {
|
||||
return Err(DocError::Invalid("the K-factor must be 0…1 and the bend radius positive".into()));
|
||||
@@ -499,6 +558,9 @@ pub struct EdgeFlange {
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
/// The most lines a path flange's path may have (each one flange and its bend).
|
||||
pub const MAX_PATH_LINES: usize = 64;
|
||||
|
||||
/// A sheet metal body (see the module docs).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SheetBody {
|
||||
@@ -541,6 +603,9 @@ pub struct SheetBody {
|
||||
/// The rule's bend relief (`relief_width`, `relief_depth`), cut into the base where a
|
||||
/// flange's bend ends beside more of the base.
|
||||
pub bend_relief: (f64, f64),
|
||||
/// How the DXF bend notes read its bend angles (the rule's `angle_display`).
|
||||
#[serde(default, skip_serializing_if = "AngleDisplay::is_bend")]
|
||||
pub angle_display: AngleDisplay,
|
||||
/// The base sketch's lines (id, ends in flat coordinates), naming the base panel's edges.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub base_curves: Vec<(String, Vec2, Vec2)>,
|
||||
@@ -685,6 +750,13 @@ pub struct BendInfo {
|
||||
pub end: Vec2,
|
||||
}
|
||||
|
||||
impl BendInfo {
|
||||
/// The angle left between the bend's two sides (degrees): 180° less `angle_deg`.
|
||||
pub fn included_angle_deg(&self) -> f64 {
|
||||
AngleDisplay::Included.of(self.angle_deg)
|
||||
}
|
||||
}
|
||||
|
||||
fn rot_about(o: Vec3, k: Vec3, a: f64) -> Mat {
|
||||
crate::rigid(Vec3::ZERO, o, k, a)
|
||||
}
|
||||
@@ -1042,6 +1114,18 @@ impl SheetBody {
|
||||
mat_mul(&self.frame_mat(), &f)
|
||||
}
|
||||
|
||||
/// A world point on flange `i`'s far edge, nearer the top face than the bottom one: where to
|
||||
/// pick that edge for a flange that stands on it (a chain of bends, one flange after another).
|
||||
/// The side is not left to a tie in the middle of the thickness: it decides which way the
|
||||
/// flange bends, and `flip` turns it the other way.
|
||||
pub fn far_edge_pick(&self, i: usize) -> Option<Vec3> {
|
||||
let f = self.flanges.get(i)?;
|
||||
let (u0, u1) = self.far_edge(f);
|
||||
let mid = f.p + f.n * (self.allowance(f) + f.leg) + f.d * ((u0 + u1) / 2.0);
|
||||
let at = apply_point(&self.world(Some(i)), v3(mid) + Vec3::Z * (self.t * 0.75));
|
||||
(at.x.is_finite() && at.y.is_finite() && at.z.is_finite()).then_some(at)
|
||||
}
|
||||
|
||||
/// Where a flat point of a piece lands (folded), at height `z` in the sheet.
|
||||
fn map3(&self, piece: Piece, q: Vec2, z: f64) -> Vec3 {
|
||||
match piece {
|
||||
@@ -2304,6 +2388,102 @@ impl SheetBody {
|
||||
self.add_flanges_each(&each, radius, position, flip, ReliefOverride::default())
|
||||
}
|
||||
|
||||
/// A path flange: a chain of edge flanges on the edge nearest `pick` that follows `path`, an
|
||||
/// open chain of world points drawn on a plane across that edge, from the end nearer the edge.
|
||||
/// Each line of the path is one flange, its length the flange's height (to the outer face from
|
||||
/// the virtual sharp, as for an edge flange), and each corner one bend: the first line's angle
|
||||
/// to the sheet makes the first bend, the turn between two lines the next. Which way the path
|
||||
/// turns says which way each bend goes. Returns the flanges made, in order, and the notes of
|
||||
/// the first one's corners.
|
||||
pub fn add_path_flange(&mut self, pick: Vec3, path: &[Vec3], radius: f64, relief: ReliefOverride) -> Result<(Vec<usize>, Vec<String>)> {
|
||||
if path.len() < 2 || path.len() > MAX_PATH_LINES + 1 {
|
||||
return Err(DocError::Invalid(format!("a path flange's path must have 1…{MAX_PATH_LINES} lines")));
|
||||
}
|
||||
if !path.iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()) {
|
||||
return Err(DocError::Invalid("the path flange's path is not finite".into()));
|
||||
}
|
||||
let (panel, a, b, top, dist) = self.edge_at(pick).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?;
|
||||
if dist > self.t * 2.0 + 1e-3 {
|
||||
return Err(DocError::Invalid("pick an edge of the sheet's top or bottom face".into()));
|
||||
}
|
||||
let d = (b - a).normalized().ok_or_else(|| DocError::Invalid("edge".into()))?;
|
||||
// The edge's frame in the world: along it, out of the sheet past it, and the top face's normal.
|
||||
let w = self.world(panel);
|
||||
let at = apply_point(&w, v3(a));
|
||||
let dir = |v: Vec3| apply_point(&w, v3(a) + v) - at;
|
||||
let (along, out, up) = (dir(v3(d)), dir(v3(Vec2::new(d.y, -d.x))), dir(Vec3::Z));
|
||||
// From the end nearer the edge.
|
||||
let off = |p: Vec3| {
|
||||
let q = p - at;
|
||||
(q - along * q.dot(along)).len()
|
||||
};
|
||||
let path: Vec<Vec3> = match (path.first(), path.last()) {
|
||||
(Some(&f), Some(&l)) if off(l) < off(f) => path.iter().rev().copied().collect(),
|
||||
_ => path.to_vec(),
|
||||
};
|
||||
// Each line's length and its direction in the plane across the edge (out, up).
|
||||
let mut lines: Vec<(f64, f64)> = Vec::with_capacity(path.len() - 1);
|
||||
for (k, pair) in path.windows(2).enumerate() {
|
||||
let [p, q] = pair else { continue };
|
||||
let v = *q - *p;
|
||||
let len = v.len();
|
||||
if !(len > 1e-6) {
|
||||
return Err(DocError::Invalid(format!("line {} of the path flange's path has no length", k + 1)));
|
||||
}
|
||||
if v.dot(along).abs() > len * 1e-4 {
|
||||
return Err(DocError::Invalid("draw the path flange's path on a plane across the edge (square to it)".into()));
|
||||
}
|
||||
let heading = v.dot(up).atan2(v.dot(out));
|
||||
// A line straight on from the one before lengthens it.
|
||||
match lines.last_mut() {
|
||||
Some((l, h)) if (heading - *h).abs() < 1e-9 => *l += len,
|
||||
_ => lines.push((len, heading)),
|
||||
}
|
||||
}
|
||||
// Each bend's turn: the first from the sheet, the others from the line before. A turn
|
||||
// toward the panel's top face is positive.
|
||||
let mut turns = Vec::with_capacity(lines.len());
|
||||
let mut before = 0.0;
|
||||
for (k, &(len, heading)) in lines.iter().enumerate() {
|
||||
let mut turn = heading - before;
|
||||
while turn > std::f64::consts::PI {
|
||||
turn -= std::f64::consts::TAU;
|
||||
}
|
||||
while turn <= -std::f64::consts::PI {
|
||||
turn += std::f64::consts::TAU;
|
||||
}
|
||||
if !(turn.abs() > 1e-6 && turn.abs() < std::f64::consts::PI - 1e-6) {
|
||||
let what = if k == 0 { "the sheet" } else { "the line before it" };
|
||||
return Err(DocError::Invalid(format!(
|
||||
"line {} of the path flange's path runs straight on from {what} or back over it: each line must turn",
|
||||
k + 1
|
||||
)));
|
||||
}
|
||||
turns.push((len, turn));
|
||||
before = heading;
|
||||
}
|
||||
let mut made = Vec::with_capacity(turns.len());
|
||||
let mut notes = Vec::new();
|
||||
for (k, &(height, turn)) in turns.iter().enumerate() {
|
||||
let line = |e: DocError| match e {
|
||||
DocError::Invalid(m) => DocError::Invalid(format!("line {}: {m}", k + 1)),
|
||||
e => e,
|
||||
};
|
||||
// The first bend on the picked face's side; each after it on the last flange's far
|
||||
// edge, picked nearer its top face, so `flip` turns it toward the bottom one.
|
||||
let (at, flip) = match made.last() {
|
||||
None => (pick, top == (turn < 0.0)),
|
||||
Some(&i) => (self.far_edge_pick(i).ok_or_else(|| DocError::Invalid("the flange has no far edge to bend again".into()))?, turn < 0.0),
|
||||
};
|
||||
let each = [EdgeFlange { pick: at, height, angle: turn.abs() }];
|
||||
let own = if k == 0 { relief } else { ReliefOverride::default() };
|
||||
let (new, more) = self.add_flanges_each(&each, radius, "inside", flip, own).map_err(line)?;
|
||||
made.extend(new);
|
||||
notes.extend(more);
|
||||
}
|
||||
Ok((made, notes))
|
||||
}
|
||||
|
||||
/// Edge flanges that each have their own height and angle, in one go: every edge is trimmed
|
||||
/// back by its own setback first, then the flanges are added and the corners between them
|
||||
/// are closed. The closing works from the two flanges it joins (each one's own fold and
|
||||
@@ -4515,6 +4695,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
|
||||
relief: rv.corner_relief_shape,
|
||||
relief_size: rv.corner_relief,
|
||||
bend_relief: (rv.relief_width, rv.relief_depth),
|
||||
angle_display: rv.angle_display,
|
||||
base_curves: Vec::new(),
|
||||
altered_by: None,
|
||||
}
|
||||
@@ -4574,6 +4755,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
|
||||
relief: rv.corner_relief_shape,
|
||||
relief_size: rv.corner_relief,
|
||||
bend_relief: (rv.relief_width, rv.relief_depth),
|
||||
angle_display: rv.angle_display,
|
||||
base_curves: Vec::new(),
|
||||
altered_by: None,
|
||||
};
|
||||
@@ -4635,9 +4817,10 @@ pub fn rename_plane_sheet_body(plane: &mut crate::PlaneRef, old: &str, new: &str
|
||||
*name = renamed;
|
||||
}
|
||||
}
|
||||
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } | crate::PlaneRef::Flipped { base, .. } => {
|
||||
rename_plane_sheet_body(base, old, new)
|
||||
}
|
||||
crate::PlaneRef::Offset { base, .. }
|
||||
| crate::PlaneRef::ThroughPoint { base, .. }
|
||||
| crate::PlaneRef::AtAngle { base, .. }
|
||||
| crate::PlaneRef::Flipped { base, .. } => rename_plane_sheet_body(base, old, new),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -4647,9 +4830,10 @@ pub fn rename_plane_sheet_body(plane: &mut crate::PlaneRef, old: &str, new: &str
|
||||
pub fn plane_sheet_face(plane: &crate::PlaneRef) -> Option<&str> {
|
||||
match plane {
|
||||
crate::PlaneRef::Face { name: Some(name), anchor: None, .. } if split_sheet_face_name(name).is_some() => Some(name),
|
||||
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } | crate::PlaneRef::Flipped { base, .. } => {
|
||||
plane_sheet_face(base)
|
||||
}
|
||||
crate::PlaneRef::Offset { base, .. }
|
||||
| crate::PlaneRef::ThroughPoint { base, .. }
|
||||
| crate::PlaneRef::AtAngle { base, .. }
|
||||
| crate::PlaneRef::Flipped { base, .. } => plane_sheet_face(base),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -4699,6 +4883,7 @@ mod tests {
|
||||
corner_relief_shape: ReliefShape::Notch,
|
||||
hem_gap: 0.01,
|
||||
gap: 2.5,
|
||||
angle_display: AngleDisplay::Bend,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5251,6 +5436,7 @@ mod tests {
|
||||
corner_relief_shape: ReliefShape::Notch,
|
||||
hem_gap: 0.01,
|
||||
gap: 2.0,
|
||||
angle_display: AngleDisplay::Bend,
|
||||
};
|
||||
let r = rect(Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(100.0, 80.0), Vec2::new(0.0, 80.0));
|
||||
let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, r);
|
||||
@@ -5346,6 +5532,7 @@ mod tests {
|
||||
corner_relief_shape: ReliefShape::Notch,
|
||||
hem_gap: 0.01,
|
||||
gap: 0.5,
|
||||
angle_display: AngleDisplay::Bend,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5593,6 +5780,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rule_names_how_its_bend_angles_read() {
|
||||
// A rule saved before the setting existed reads bend angles, and saves as it did.
|
||||
let old: SheetRule = serde_json::from_str(r#"{"name":"Old","thickness":"2 mm","k_factor":"0.5","bend_radius":"1 mm"}"#).unwrap();
|
||||
assert_eq!(old.angle_display, "bend");
|
||||
assert_eq!(SheetRule::steel().angle_display, "bend");
|
||||
assert!(serde_json::to_value(&old).unwrap().get("angle_display").is_none());
|
||||
let (doc, vals) = (Document::default(), std::collections::BTreeMap::new());
|
||||
assert_eq!(doc.rule_values(&vals, &old).unwrap().angle_display, AngleDisplay::Bend);
|
||||
for (text, display) in [("included", AngleDisplay::Included), (" Included ", AngleDisplay::Included), ("BEND", AngleDisplay::Bend)] {
|
||||
let rule = SheetRule { angle_display: text.into(), ..old.clone() };
|
||||
assert_eq!(doc.rule_values(&vals, &rule).unwrap().angle_display, display);
|
||||
assert_eq!(AngleDisplay::parse(display.name()), Some(display));
|
||||
}
|
||||
// An included rule is saved with the setting and read back the same.
|
||||
let included = SheetRule { angle_display: "included".into(), ..old.clone() };
|
||||
let saved = serde_json::to_value(&included).unwrap();
|
||||
assert_eq!(saved["angle_display"], "included");
|
||||
assert_eq!(serde_json::from_value::<SheetRule>(saved).unwrap(), included);
|
||||
// No other word is a display.
|
||||
for text in ["deflection", "inside", ""] {
|
||||
let err = doc.rule_values(&vals, &SheetRule { angle_display: text.into(), ..old.clone() }).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("unknown bend angle display `{text}` (bend or included)")), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The included angle is what a bend leaves between its sides: 180° less the bend angle, so a
|
||||
/// 90° bend reads the same both ways. A body keeps its rule's display.
|
||||
#[test]
|
||||
fn the_included_angle_is_180_less_the_bend_angle() {
|
||||
for (deg, included) in [(58.0, 122.0), (90.0, 90.0), (120.0, 60.0)] {
|
||||
let bends = flanged(f64::to_radians(deg)).bends();
|
||||
assert_eq!(bends.len(), 1);
|
||||
assert!((bends[0].angle_deg - deg).abs() < 1e-9 && (bends[0].included_angle_deg() - included).abs() < 1e-9, "{bends:?}");
|
||||
assert!((AngleDisplay::Bend.of(bends[0].angle_deg) - deg).abs() < 1e-9);
|
||||
assert!((AngleDisplay::Included.of(bends[0].angle_deg) - included).abs() < 1e-9);
|
||||
}
|
||||
let r = rect(Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(100.0, 80.0), Vec2::new(0.0, 80.0));
|
||||
assert_eq!(base_flange("Body1", "Steel", &steel(), Plane::XY, r.clone()).angle_display, AngleDisplay::Bend);
|
||||
let shop = RuleValues { angle_display: AngleDisplay::Included, ..steel() };
|
||||
assert_eq!(base_flange("Body1", "Shop", &shop, Plane::XY, r).angle_display, AngleDisplay::Included);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_reliefs_cut_the_base_corner_and_stop_the_bends() {
|
||||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4};
|
||||
|
||||
@@ -469,3 +469,26 @@ fn self_referencing_construction_planes_are_errors() {
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().any(|r| r.error.is_some()), "a cyclic perpendicular plane must report an error: {:?}", m.results);
|
||||
}
|
||||
|
||||
/// New names take the first free number after their stem, whatever else is named alike: a gap
|
||||
/// is filled, and `Sketch07`, `Sketch+2` or `SketchLid` take no number (they are not `Sketch7`).
|
||||
#[test]
|
||||
fn new_names_take_the_first_free_number() {
|
||||
let taken = crate::params::numbers_after("Box", ["Box1", "Box3", "Box07", "Box+2", "BoxLid", "Box", "Cylinder2"].into_iter());
|
||||
assert_eq!(taken, std::collections::HashSet::from([1, 3]));
|
||||
let mut doc = Document::new("names");
|
||||
let sketch = |doc: &mut Document| {
|
||||
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, None).unwrap()
|
||||
};
|
||||
let name = |doc: &Document, id: u64| doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
let ids: Vec<u64> = (0..3).map(|_| sketch(&mut doc)).collect();
|
||||
let names: Vec<String> = ids.iter().map(|id| name(&doc, *id)).collect();
|
||||
assert!(names.iter().all(|n| n.starts_with("Sketch")) && names[0] != names[1] && names[1] != names[2], "{names:?}");
|
||||
for (id, other) in [(ids[0], "SketchLid"), (ids[1], "Sketch07")] {
|
||||
if let Some(f) = doc.feature_mut(id) {
|
||||
f.name = other.into();
|
||||
}
|
||||
}
|
||||
let next = sketch(&mut doc);
|
||||
assert_eq!(name(&doc, next), names[0], "the first freed number is taken again");
|
||||
}
|
||||
|
||||
+10
-2
@@ -336,7 +336,7 @@ impl Walk for PlaneRef {
|
||||
PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
|
||||
// A plane seen from behind keeps its base's expressions (a sketch made with
|
||||
// `flip` on an offset plane did not follow the offset's parameter).
|
||||
PlaneRef::Flipped { base, .. } => p = base,
|
||||
PlaneRef::Flipped { base, .. } | PlaneRef::ThroughPoint { base, .. } => p = base,
|
||||
PlaneRef::Placed { origin, .. } => {
|
||||
for (label, e) in ["X", "Y", "Z"].into_iter().zip(origin) {
|
||||
f((label, e, Kind::Length, ExprRole::Placement));
|
||||
@@ -362,7 +362,7 @@ impl Walk for PlaneRef {
|
||||
PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
|
||||
// A plane seen from behind keeps its base's expressions (a sketch made with
|
||||
// `flip` on an offset plane did not follow the offset's parameter).
|
||||
PlaneRef::Flipped { base, .. } => p = base,
|
||||
PlaneRef::Flipped { base, .. } | PlaneRef::ThroughPoint { base, .. } => p = base,
|
||||
PlaneRef::Placed { origin, .. } => {
|
||||
for (label, e) in ["X", "Y", "Z"].into_iter().zip(origin) {
|
||||
f((label, e, Kind::Length, ExprRole::Placement));
|
||||
@@ -395,6 +395,10 @@ impl PlaneRef {
|
||||
match self {
|
||||
Self::Construction { name } => f(Ref::Feature(name)),
|
||||
Self::Offset { base, .. } | Self::AtAngle { base, .. } | Self::Flipped { base, .. } => base.walk_feature_refs(f, depth + 1),
|
||||
Self::ThroughPoint { base, point } => {
|
||||
base.walk_feature_refs(f, depth + 1);
|
||||
point.walk_feature_refs(f, depth + 1);
|
||||
}
|
||||
Self::Midplane { a, b } | Self::TwoEdges { a, b } | Self::Perpendicular { plane: a, line: b } => {
|
||||
a.walk_feature_refs(f, depth + 1);
|
||||
b.walk_feature_refs(f, depth + 1);
|
||||
@@ -416,6 +420,10 @@ impl PlaneRef {
|
||||
match self {
|
||||
Self::Construction { name } => f(Ref::Feature(name)),
|
||||
Self::Offset { base, .. } | Self::AtAngle { base, .. } | Self::Flipped { base, .. } => base.walk_feature_refs_mut(f, depth + 1),
|
||||
Self::ThroughPoint { base, point } => {
|
||||
base.walk_feature_refs_mut(f, depth + 1);
|
||||
point.walk_feature_refs_mut(f, depth + 1);
|
||||
}
|
||||
Self::Midplane { a, b } | Self::TwoEdges { a, b } | Self::Perpendicular { plane: a, line: b } => {
|
||||
a.walk_feature_refs_mut(f, depth + 1);
|
||||
b.walk_feature_refs_mut(f, depth + 1);
|
||||
|
||||
@@ -39,6 +39,7 @@ solidcraft-io = { workspace = true }
|
||||
solidcraft-render = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml_edit = "0.25"
|
||||
schemars = { workspace = true }
|
||||
serde_path_to_error = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -46,6 +47,8 @@ log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Model-cache tests also run in a source tarball build (no git commit).
|
||||
solidcraft-io = { workspace = true, features = ["trust-unknown-source"] }
|
||||
# Normal CI exercises profiling assertions without enabling the production feature.
|
||||
solidcraft-sketch = { workspace = true, features = ["solver-profiling"] }
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user