Compare commits
247 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0cc740cc3 | |||
| 511faedcfa | |||
| c5129a45d7 | |||
| db75b7bfac | |||
| 4dcca282da | |||
| e98e065584 | |||
| af32243df1 | |||
| 885d2aafbb | |||
| c7bd467be7 | |||
| fb811fbf21 | |||
| 843c0bf97a | |||
| 50da77abc4 | |||
| 95de388ba1 | |||
| d8eb65d26e | |||
| 7c6845da95 | |||
| 9c26f6dbc9 | |||
| 2061ad1694 | |||
| e37a8fa6bb | |||
| b3811168fc | |||
| c64a611818 | |||
| 9a0e6f8ae0 | |||
| 689038d187 | |||
| 3f7959b536 | |||
| a7bc7fe2ff | |||
| 876d57a67b | |||
| 47108fa063 | |||
| ca9ebb44db | |||
| e44dd4ff9e | |||
| ebada963a4 | |||
| 9666c0be56 | |||
| 9506336829 | |||
| 9dfa9e2d52 | |||
| c5b637600c | |||
| 3855c4c7e2 | |||
| d60b438f42 | |||
| 2ace233cd4 | |||
| c83446c6c9 | |||
| 349a6c6fc4 | |||
| f6cf8ceaad | |||
| 83ecf91ae7 | |||
| a3f28c0b89 | |||
| ac757960dc | |||
| 953fd7d5f8 | |||
| ae2ab87b99 | |||
| 8a562f2719 | |||
| e9b1a077d7 | |||
| 37a8402cae | |||
| 9e15995a74 | |||
| 926a7353a3 | |||
| c400188778 | |||
| 58303ba036 | |||
| fe3b14e798 | |||
| e27d43553c | |||
| de7fabc3bc | |||
| 1b6c10f6e0 | |||
| 59b6eea516 | |||
| b168750fb3 | |||
| 25ea9e6419 | |||
| fa3ada1983 | |||
| 759e22b582 | |||
| 14b7529465 | |||
| bdfaf690d7 | |||
| b4520d265e | |||
| 46a0b25280 | |||
| ca9c363a98 | |||
| d0afc31632 | |||
| 80719744cf | |||
| 2de99c728e | |||
| adaac4d1b6 | |||
| 0a328a3d0a | |||
| 74af4a3471 | |||
| 9e7a7b260b | |||
| e661e6f809 | |||
| b622a46624 | |||
| 3ffd8efffc | |||
| 26dd2548b7 | |||
| 07be3f8852 | |||
| 33d7a86d50 | |||
| e984e7d738 | |||
| 62b49f9f89 | |||
| c8283f2196 | |||
| f97a84c100 | |||
| af436493a2 | |||
| 57b52d8a49 | |||
| d55521aa3e | |||
| 68c7208203 | |||
| 91753051a1 | |||
| b2622325a1 | |||
| b93d8fa0b1 | |||
| 843726a8cb | |||
| 65e6d9dafd | |||
| 803ae6d5be | |||
| d90ec9586c | |||
| a108ffac38 | |||
| ee67ed53af | |||
| 504d7c9488 | |||
| 206fffdd80 | |||
| 54b00fa7b1 | |||
| a9f621837f | |||
| 68ac9976f3 | |||
| 9c954b46ed | |||
| 66951921a1 | |||
| 98c30d28f9 | |||
| 4d99d79205 | |||
| 44cbd7ae73 | |||
| 53a9260aeb | |||
| e5291dc8f7 | |||
| a1badaed17 | |||
| 3d105fb281 | |||
| 0b787f8eae | |||
| a6b44ab0d3 | |||
| b0c4b6e939 | |||
| edc01b19bf | |||
| 69db6ee6e4 | |||
| 247cf357b6 | |||
| f945e79278 | |||
| fd5795b5ad | |||
| f1b721b00c | |||
| c41845e6d7 | |||
| 2f92aaee6c | |||
| 6b4026fd19 | |||
| e9b850572d | |||
| b045d9deae | |||
| 7555b34afb | |||
| 948c208db9 | |||
| 983fbe2b14 | |||
| 80dcf9de7f | |||
| 02c77df5f7 | |||
| 77c3b1e765 | |||
| 28d67fb577 | |||
| 1999e763ab | |||
| 750e08aab7 | |||
| 7ce9c29f95 | |||
| 31b41978fc | |||
| f42c068063 | |||
| 26f42ae09b | |||
| f68a3dd5d5 | |||
| 72601e678a | |||
| 42e991069f | |||
| c1f9224579 | |||
| b0b59480eb | |||
| e1268c6ad9 | |||
| 473f1e6516 | |||
| 539c17b425 | |||
| d3fa22e987 | |||
| 45ae7c00e0 | |||
| 24d64799d8 | |||
| a9c7616e7f | |||
| d306d6e658 | |||
| b20e693c2e | |||
| b3f205a638 | |||
| bd4eb42c2d | |||
| 45d0b6053b | |||
| 99d8d382b9 | |||
| d8d04c9009 | |||
| 6f93e7a851 | |||
| 1cd9983c8f | |||
| f1f5298bc5 | |||
| 15db51304e | |||
| b00d45cc7b | |||
| f3505f0db6 | |||
| e4d9015f68 | |||
| 3b4db52c10 | |||
| fb82ccb273 | |||
| 3115c50359 | |||
| 5bed5196c7 | |||
| b4e049f9a6 | |||
| b76302e850 | |||
| 771f62be71 | |||
| aee47fca00 | |||
| 4c30d944f5 | |||
| 5f399207bf | |||
| 7fd51ce6a5 | |||
| aef13b52f4 | |||
| 355154fa9b | |||
| f1a5f4ab30 | |||
| 3a9b053f5b | |||
| aaa35a94d8 | |||
| 852d364d5f | |||
| 34cb3167b6 | |||
| 1666ca7018 | |||
| 768e69c4c7 | |||
| 5997d28de7 | |||
| 5122b98af3 | |||
| f9342aa8b6 | |||
| d9c3fdb80f | |||
| dc27988bc2 | |||
| e3831b05d4 | |||
| 59ba87103e | |||
| 19ffb0dd75 | |||
| c66e33284b | |||
| 57d210130c | |||
| 71534117af | |||
| 38f6ed76c9 | |||
| 66108a6b99 | |||
| 8e762fee57 | |||
| 6b1fc16d47 | |||
| 1ea85ff231 | |||
| 2aa808b116 | |||
| aa438611a7 | |||
| 86b2b97d6f | |||
| c177adfceb | |||
| 7ad5130cd2 | |||
| a3080d02d6 | |||
| 17e173cb7b | |||
| 46bc44b706 | |||
| 9eb1540a34 | |||
| 3a1e1eed39 | |||
| 9ee2de9a0f | |||
| ed64988d8f | |||
| 03e6c3d468 | |||
| 5de48e916d | |||
| 80af7bbe3e | |||
| 191e983423 | |||
| cbf589eb8c | |||
| cd85efd73d | |||
| 2452513b65 | |||
| 9c411859bc | |||
| 22a86a744e | |||
| 143435dc1c | |||
| 36aa4a15fe | |||
| e887792182 | |||
| 2b9445b049 | |||
| 7ddf48ca16 | |||
| d2f6848377 | |||
| 0b6d6fecd8 | |||
| a940f77447 | |||
| c59842b8e1 | |||
| a587d3339e | |||
| fa691707c2 | |||
| b9a55c74e2 | |||
| 2bb9c81069 | |||
| cedb51b8ea | |||
| 66d13059dd | |||
| 800ea40c0d | |||
| 20442eecd3 | |||
| 22d1cd7032 | |||
| 6a0dc18655 | |||
| e006dbf1af | |||
| 8241a750fa | |||
| 41779cf7bb | |||
| b01b9689c8 | |||
| 4426da6f5d | |||
| 5ead82e627 | |||
| c29b0d2f13 | |||
| 480fd8d517 | |||
| 4c09fa5355 |
@@ -1,8 +1,9 @@
|
||||
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
|
||||
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
|
||||
# CI builds the tests once and runs them in shards: 2 for a pull request, 4 for main and the nightly
|
||||
# (.github/workflows/ci.yml).
|
||||
|
||||
[profile.ci]
|
||||
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
|
||||
# Heavy tests (one to twelve minutes each on a CI runner) that pull requests skip. They run on
|
||||
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`. The
|
||||
# boolean_props property tests run only nightly (ci.yml excludes them from pushes to main).
|
||||
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
|
||||
@@ -17,6 +18,7 @@ not (
|
||||
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
|
||||
))
|
||||
| (package(solidcraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
|
||||
| (package(solidcraft-kernel) & test(=tspline::tests::hostile_tsplines_never_panic))
|
||||
| (package(solidcraft-kernel) & binary(boolean_props))
|
||||
| (package(solidcraft-engine) & binary(atomic_commands))
|
||||
| (package(solidcraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
|
||||
@@ -39,3 +41,30 @@ path = "junit.xml"
|
||||
filter = "binary(action_matrix) & test(=action_matrix_sample)"
|
||||
threads-required = "num-cpus"
|
||||
|
||||
|
||||
# The slowest tests start first, so a shard does not end with one of them running alone on a
|
||||
# core while the others sit idle (seed_81 alone takes about 20 minutes under CI load). Medians
|
||||
# from the CI test timings (the junit artifacts), slowest first; a test over ~100 s belongs here.
|
||||
[[profile.ci.overrides]]
|
||||
filter = """
|
||||
(package(solidcraft-engine) & (
|
||||
test(/^tests::robustness_goldens::seed_(81|45|20|24|95|02|57|40|18|48|32|46|85|61)$/)
|
||||
| test(/^tests::robustness::/)
|
||||
| test(=tests::hostile_params_never_panic)
|
||||
| test(=cmd::clearance::tests::distances_are_exact_on_edges_and_curved_faces)
|
||||
| test(=cmd::clipboard::tests::a_pasted_coil_joins_the_pasted_body)
|
||||
| test(=tests::pleat_join::the_pleats_join_at_every_wall_from_2_4_to_2_6_mm)
|
||||
| test(=tests::flush_join_loft::a_flange_flush_with_the_end_of_a_bent_tapered_tube_joins)
|
||||
| test(=cmd::sheet::tests::perforated_sheet_takes_a_thousand_round_holes)
|
||||
| test(=boolean_regression_tests::joined_loft_frames_plan_their_display_at_every_quality)
|
||||
))
|
||||
| (package(solidcraft-engine) & (binary(atomic_commands) | binary(step_export_regressions)))
|
||||
| (package(solidcraft-kernel) & (
|
||||
test(=curveblend::tests::rolling_ball_turns_about_a_rounded_corner_of_its_radius)
|
||||
| test(=tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole)
|
||||
| test(=tspline::tests::hostile_tsplines_never_panic)
|
||||
| test(=fill::tests::regular_rims_of_three_to_eight_sides_fill_with_one_face_and_close_the_cup)
|
||||
| test(/^issue_788_tests::equal_bores_crossing_/)
|
||||
))
|
||||
"""
|
||||
priority = 100
|
||||
|
||||
@@ -2,3 +2,9 @@ self-hosted-runner:
|
||||
labels:
|
||||
- self-hosted-fast
|
||||
- self-hosted-light
|
||||
# Blacksmith (see the top of workflows/ci.yml).
|
||||
- blacksmith-2vcpu-ubuntu-2404
|
||||
- blacksmith-4vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-ubuntu-2404
|
||||
- blacksmith-16vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-windows-2025
|
||||
|
||||
@@ -2,38 +2,50 @@
|
||||
# PRs test what they touch; main and nightly test everything. On a pull request the "Select
|
||||
# checks" job maps the changed files to workspace packages (scripts/ci/affected.py), adds the
|
||||
# packages that depend on them, and Clippy, the test archive and the test shards cover only
|
||||
# those. Changes to the lockfile, root manifest, toolchain/cargo/nextest config, .github/,
|
||||
# vendor/, tests/, scripts/ or any file no package owns select the whole workspace, as do
|
||||
# pushes to main, the nightly run and Run workflow.
|
||||
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# those. Test data under tests/, vendored crates under vendor/ and root files a crate embeds
|
||||
# select the packages that read them. Changes to the lockfile, root manifest,
|
||||
# toolchain/cargo/nextest config, this workflow, scripts/ or any file no package owns select the
|
||||
# whole workspace, as do pushes to main that change code, the nightly run and Run workflow.
|
||||
# Documentation, packaging, the other workflows and the GitHub bots' scripts select nothing:
|
||||
# no native job runs for them, on pull requests or on main.
|
||||
# Tests are built once and run in shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
|
||||
# and app startup) run on pull requests only when what they test changes (the GPU crate, the app,
|
||||
# the viewport's GPU glue, the graphics dependencies, their scripts or this file); main and
|
||||
# nightly always run them. Only main saves the build caches; pull requests restore them.
|
||||
# The merge queue runs the full suite on exactly the commit it then puts on main, so a push to
|
||||
# main that a passing merge-queue run already tested runs only the repository checks; a push
|
||||
# that skipped the queue still runs everything. Pull requests run the reference tests when the
|
||||
# command line (or anything it depends on) changes, and doc tests for the affected packages.
|
||||
# and app startup), the reference tests and the doc tests run on main and nightly only, not on
|
||||
# pull requests (the fast lane, in Select checks); a break there is fixed forward on main. Only
|
||||
# main saves the build caches; pull requests restore them. On pull requests the test build and the
|
||||
# shards start only after the format and repository checks pass.
|
||||
# A push to main runs only the repository checks when a passing merge-queue run tested that very
|
||||
# commit, or when nothing a build reads changed since the last push to main whose run passed.
|
||||
# Otherwise it runs everything.
|
||||
# Heavy checks wait for review: a pull request without the `approved-for-ci` label runs only
|
||||
# Select checks, the format and repository checks and a quick compile. Adding the label (after
|
||||
# review) runs everything above, and every later push to the PR does too, except a push that only
|
||||
# merges main into a change that already passed (scripts/ci/main_merge.py): it carries that
|
||||
# result and runs the repository checks. Merge groups, the nightly run and Run workflow are not
|
||||
# pull requests and always run the full suite.
|
||||
# GitHub cannot filter `labeled` events by label name, so any other label starts a run whose jobs
|
||||
# are all skipped; it gets its own concurrency group (below) so it never cancels the real run. Its
|
||||
# `ci` job still runs and reports the commit's real result (see the `ci` job): the merge queue
|
||||
# reads `ci` from the newest run of a commit, so a run without it would block a PR that passed.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
# Trusted runs use both pools: pushes to main, merge-queue groups and pull requests from a branch
|
||||
# of this repository (people with write access; fork pull requests never run on this machine).
|
||||
# There, Select checks and ci run on the self-hosted light runner (`self-hosted-light`, quick
|
||||
# jobs, so they never wait behind a build), and the repository checks, Clippy, the doc tests,
|
||||
# the reference tests and all four test shards on the self-hosted runners (`self-hosted-fast`, warm
|
||||
# build directories via scripts/ci/warm-target.sh). Fork PRs' Linux jobs run on the isolated, ephemeral
|
||||
# `self-hosted-untrusted` runners (no warm caches) when the PR number ends in 7, 8 or 9 (the rest of
|
||||
# the fork PRs, and everything else, run on GitHub's runners, which have far more free slots).
|
||||
# The test archive is always built on GitHub's runners: the self-hosted machine has a newer
|
||||
# glibc, so its binaries wouldn't start on ubuntu-24.04, while the archive runs there unchanged
|
||||
# (same /home/runner/work checkout path). Fork pull requests, the nightly run and Run workflow
|
||||
# use GitHub's runners only; the self-hosted runners refuse any other job.
|
||||
# Every Linux job runs on Blacksmith's Ubuntu 24.04 runners (blacksmith-<N>vcpu-ubuntu-2404), sized
|
||||
# per job: 16 vCPU for Build tests, Clippy and the merge queue's test shards, 8 for compiles, the
|
||||
# other test shards, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
|
||||
# The Windows shader job runs on Blacksmith's Windows Server 2025 runners (blacksmith-8vcpu-windows-2025);
|
||||
# the Windows app startup smoke stays on windows-latest, which has a desktop session that presents frames.
|
||||
# Pull requests, forks included, the merge queue, pushes to main and the nightly run all use them:
|
||||
# the runners are ephemeral, fork runs get no secrets, and main saves the build caches that every
|
||||
# other run restores read-only. The tests are built once (Build tests) into an archive the shards
|
||||
# run; Blacksmith's image matches ubuntu-24.04, so the archive and the recorded goldens behave the
|
||||
# same.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# Draft pull requests run no CI (see `changes`, `checks` and `ci`): it starts when the PR is
|
||||
# marked ready for review, and on every push after that.
|
||||
# marked ready for review, and on every push after that. `labeled` is there for approved-for-ci
|
||||
# only (other labels are skipped, see the top of this file).
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
types: [opened, synchronize, reopened, ready_for_review, labeled]
|
||||
# The merge queue: each queued group runs the full suite before it lands on main.
|
||||
merge_group:
|
||||
push:
|
||||
@@ -53,7 +65,10 @@ permissions:
|
||||
concurrency:
|
||||
# A new PR push replaces the old run. Only one run per group may wait, so a busy main would
|
||||
# cancel the waiting nightly (or a Run workflow) before it starts: they get groups of their own.
|
||||
group: ci-${{ github.event_name }}-${{ github.ref }}
|
||||
# A `labeled` event for any label but approved-for-ci does no work and must not cancel (or queue
|
||||
# behind) the PR's real run, so it gets a group of its own; approved-for-ci replaces the
|
||||
# unlabelled run with the full one.
|
||||
group: ci-${{ github.event_name }}-${{ github.ref }}${{ github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci' && format('-ignored-{0}', github.run_id) || '' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
@@ -63,7 +78,7 @@ jobs:
|
||||
dependency-advisories:
|
||||
name: Dependency advisories
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -75,10 +90,10 @@ jobs:
|
||||
|
||||
changes:
|
||||
name: Select checks
|
||||
# Not on a draft: every other check needs this one, so they skip too.
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
# Trusted runs (see the top of this file) use the self-hosted runners.
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
# Not on a draft: every other check needs this one, so they skip too. Not on a label other than
|
||||
# approved-for-ci either (the same skip, for the same reason).
|
||||
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
# To ask whether the merge queue already tested a commit pushed to main.
|
||||
@@ -89,12 +104,13 @@ jobs:
|
||||
goldens: ${{ steps.paths.outputs.goldens }}
|
||||
windows: ${{ steps.paths.outputs.windows }}
|
||||
full: ${{ steps.paths.outputs.full }}
|
||||
# Code changed on a pull request that has no approved-for-ci label yet: only Quick compile.
|
||||
quick: ${{ steps.paths.outputs.quick }}
|
||||
packages: ${{ steps.paths.outputs.packages }}
|
||||
oracle: ${{ steps.paths.outputs.oracle }}
|
||||
# Whether the other Linux jobs use the self-hosted runners too.
|
||||
# Trusted self-hosted runs only: a fork PR's checks run on the untrusted pool and must never
|
||||
# route the jobs after them to the trusted runners.
|
||||
local: ${{ runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
|
||||
doctests: ${{ steps.paths.outputs.doctests }}
|
||||
# The passing run a merge of main carries forward (its URL), when this push is only that.
|
||||
carried: ${{ steps.paths.outputs.carried }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -106,7 +122,11 @@ jobs:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_SHA: ${{ github.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
REPO: ${{ github.repository }}
|
||||
APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'approved-for-ci') }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
native=false
|
||||
@@ -115,17 +135,38 @@ jobs:
|
||||
windows=false
|
||||
full=true
|
||||
packages=
|
||||
# Whether the NUL-separated paths in $1 reach any code: scripts/ci/affected.py selects
|
||||
# something or the whole workspace. Documentation, packaging, other workflows and the bots'
|
||||
# scripts select nothing. Pull requests and pushes to main share it.
|
||||
select_packages() {
|
||||
python3 -I scripts/ci/affected.py --paths "$1" > "$RUNNER_TEMP/ci-affected"
|
||||
cat "$RUNNER_TEMP/ci-affected"
|
||||
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
|
||||
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
|
||||
}
|
||||
if [ "$EVENT_NAME" = push ]; then
|
||||
# The queue lands the very commit its merge_group run tested. If that run passed,
|
||||
# testing the push again proves nothing new; otherwise (a merge that skipped the
|
||||
# queue, or the API cannot answer) test everything.
|
||||
# curl and grep only: Select checks of a push runs on the self-hosted runner.
|
||||
# The API through curl and grep, which every runner image has.
|
||||
tested=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/$REPO/actions/workflows/ci.yml/runs?event=merge_group&head_sha=$PUSH_SHA&status=success&per_page=1" \
|
||||
| grep -o '"total_count": *[0-9]*' | grep -o '[0-9]*$' || echo 0)
|
||||
if [ "${tested:-0}" -gt 0 ]; then
|
||||
echo "The merge queue already tested $PUSH_SHA: repository checks only."
|
||||
elif tested=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/$REPO/actions/workflows/ci.yml/runs?event=push&branch=main&status=success&per_page=1" \
|
||||
| grep -o '"head_sha": *"[0-9a-f]*"' | head -n 1 | grep -o '[0-9a-f]\{40\}') \
|
||||
&& [ -n "$tested" ] && git merge-base --is-ancestor "$tested" "$PUSH_SHA" \
|
||||
&& git diff --no-renames --name-only -z "$tested" "$PUSH_SHA" > "$RUNNER_TEMP/ci-paths" \
|
||||
&& select_packages "$RUNNER_TEMP/ci-paths" && [ "$full" != true ] && [ -z "$packages" ]; then
|
||||
# Nothing a build reads changed since the last push to main whose run passed (the
|
||||
# push before may never have run: a newer push cancels a waiting one).
|
||||
echo "$tested..$PUSH_SHA changes no code: repository checks only."
|
||||
else
|
||||
# Code changed (or the push can't be compared): main runs everything.
|
||||
full=true
|
||||
packages=
|
||||
native=true
|
||||
windows=true
|
||||
fi
|
||||
@@ -139,7 +180,7 @@ jobs:
|
||||
case "$path" in crates/kernel/*|vendor/truck-*) kernel=true ;; esac
|
||||
# What can change a robustness golden: the kernel and the crates a design is built with.
|
||||
case "$path" in
|
||||
crates/kernel/*|crates/engine/*|crates/doc/*|crates/sketch/*|crates/geom/*|vendor/truck-*) goldens=true ;;
|
||||
crates/kernel/*|crates/engine/*|crates/doc/*|crates/sketch/*|crates/geom/*|vendor/truck-*|tests/robustness/*) goldens=true ;;
|
||||
esac
|
||||
# What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and
|
||||
# its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts.
|
||||
@@ -147,14 +188,6 @@ jobs:
|
||||
crates/gpu/*|apps/solidcraft/*|crates/ui-egui/src/gpu.rs|vendor/egui-wgpu/*) windows=true ;;
|
||||
tools/windows-*.ps1|.github/workflows/ci.yml|rust-toolchain*|.cargo/*) windows=true ;;
|
||||
esac
|
||||
case "$path" in
|
||||
.github/workflows/ci.yml) native=true ;;
|
||||
*.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;;
|
||||
docs/*|*.md|.github/*|packaging/*|LICENSE*|NOTICE|THIRD-PARTY-LICENSES.txt) ;;
|
||||
# The GitHub bots' scripts: no Rust, tested in this job (Claim and triage bot tests).
|
||||
scripts/claim/*|scripts/triage/*) ;;
|
||||
*) native=true ;;
|
||||
esac
|
||||
done < "$RUNNER_TEMP/ci-paths"
|
||||
# A lockfile change that moves the graphics stack (wgpu, naga, egui, winit, windows).
|
||||
if [ "$windows" = false ] && grep -qxz 'Cargo.lock' "$RUNNER_TEMP/ci-paths" \
|
||||
@@ -162,13 +195,10 @@ jobs:
|
||||
| grep -Eq '^[ +-]name = "(wgpu[a-z_-]*|naga|egui[a-z_-]*|eframe|winit|windows[a-z_-]*|d3d12)"'; then
|
||||
windows=true
|
||||
fi
|
||||
# Pull requests build and test the touched packages and their dependents.
|
||||
python3 -I scripts/ci/affected.py --paths "$RUNNER_TEMP/ci-paths" > "$RUNNER_TEMP/ci-affected"
|
||||
cat "$RUNNER_TEMP/ci-affected"
|
||||
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
|
||||
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
|
||||
# Code changed but nothing was selected: be safe and test everything.
|
||||
if [ "$native" = true ] && [ "$full" != true ] && [ -z "$packages" ]; then full=true; fi
|
||||
# Pull requests build and test the touched packages and their dependents; nothing
|
||||
# selected means no code changed.
|
||||
select_packages "$RUNNER_TEMP/ci-paths"
|
||||
if [ "$full" = true ] || [ -n "$packages" ]; then native=true; fi
|
||||
# The goldens are solidcraft-engine tests: its tests must be in the archive.
|
||||
if [ "$goldens" = true ] && [ "$full" != true ]; then
|
||||
case " $packages " in *" solidcraft-engine "*) ;; *) packages="$packages solidcraft-engine" ;; esac
|
||||
@@ -180,36 +210,60 @@ jobs:
|
||||
if [ "$EVENT_NAME" = pull_request ] && [ "$full" != true ]; then
|
||||
case " $packages " in *" solidcraft-cli "*) ;; *) oracle=false ;; esac
|
||||
fi
|
||||
# The pull request fast lane: Windows and the reference tests run on main (and the nightly),
|
||||
# not on every pull request push; a break there is fixed forward on main.
|
||||
if [ "$EVENT_NAME" = pull_request ]; then windows=false; oracle=false; fi
|
||||
# The pull request fast lane: Windows, the reference tests and the doc tests run on main
|
||||
# (and the nightly), not on every pull request push; a break there is fixed forward on
|
||||
# main. Over three days of pull requests the doc tests were the only failing job 3 times
|
||||
# in about 7,900 runner-minutes, and the reference tests never were.
|
||||
doctests=$native
|
||||
if [ "$EVENT_NAME" = pull_request ]; then windows=false; oracle=false; doctests=false; fi
|
||||
# Until a maintainer adds approved-for-ci, a pull request gets a quick compile instead of
|
||||
# Clippy, the tests, the doc tests and the goldens.
|
||||
quick=false
|
||||
if [ "$EVENT_NAME" = pull_request ] && [ "$APPROVED" != true ]; then
|
||||
quick=$native
|
||||
native=false
|
||||
oracle=false
|
||||
goldens=false
|
||||
fi
|
||||
# A push that only merges main into the change (Merge branch 'main', Update branch) when
|
||||
# the change itself already passed: nothing new to test here, and main tests the merged
|
||||
# result after it lands. Over three days, 872 of 1,241 such merges brought in nothing that
|
||||
# touched the pull request's files, and each restarted the whole run.
|
||||
carried=
|
||||
if [ "$EVENT_NAME" = pull_request ] && [ "$native" = true ]; then
|
||||
carried=$(python3 -I scripts/ci/main_merge.py --base "$BASE_SHA" --head "$HEAD_SHA" --repo "$REPO" \
|
||||
--pr "$PR_NUMBER" --head-repo "$HEAD_REPO" --head-ref "$HEAD_REF" | sed -n 's/^carried=//p')
|
||||
if [ -n "$carried" ]; then
|
||||
echo "Only a merge of main into a change that passed in $carried: repository checks only."
|
||||
native=false
|
||||
goldens=false
|
||||
fi
|
||||
fi
|
||||
{
|
||||
echo "native=$native"
|
||||
echo "kernel=$kernel"
|
||||
echo "goldens=$goldens"
|
||||
echo "windows=$windows"
|
||||
echo "oracle=$oracle"
|
||||
echo "doctests=$doctests"
|
||||
echo "carried=$carried"
|
||||
echo "full=$full"
|
||||
echo "quick=$quick"
|
||||
echo "packages=$packages"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
checks:
|
||||
name: Format and repository checks
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh checks
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: repo-checks
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -231,27 +285,43 @@ jobs:
|
||||
run: cargo xtask web-offline-test
|
||||
- name: Affected-package selection self-test
|
||||
run: python3 -I scripts/ci/test_affected.py
|
||||
- name: Merge-of-main detection self-test
|
||||
run: python3 -I scripts/ci/test_main_merge.py
|
||||
- name: Claim and triage bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
|
||||
|
||||
# Fast feedback for a pull request that has not been approved for the full CI yet.
|
||||
quick-compile:
|
||||
name: Quick compile
|
||||
needs: changes
|
||||
if: needs.changes.outputs.quick == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: quick-compile
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Cargo check
|
||||
run: cargo check --workspace --all-targets --locked
|
||||
|
||||
clippy:
|
||||
name: Native Linux Clippy
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh clippy
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-clippy
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -267,11 +337,15 @@ jobs:
|
||||
|
||||
build-tests:
|
||||
name: Build tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04' }}
|
||||
# Fork builds compile inside gVisor on shared boxes; give them room to finish and upload.
|
||||
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 90 || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
|
||||
# Fail fast on pull requests: the test archive (and so the shards) waits for the repository
|
||||
# checks, the most common cheap failure; heavy jobs used to run on to the end beside a failed
|
||||
# format check. Main, nightly and Run workflow build regardless, so the action matrix and the
|
||||
# property tests that hang off this job keep their signal.
|
||||
needs: [changes, checks]
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' && (github.event_name != 'pull_request' || needs.checks.result == 'success') }}
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
# Fork builds start from main's cache, read-only; give them room to finish and upload.
|
||||
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
@@ -314,8 +388,9 @@ jobs:
|
||||
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: [changes, build-tests]
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
needs: [changes, checks, build-tests]
|
||||
if: ${{ !cancelled() && needs.changes.outputs.native == 'true' && (github.event_name != 'pull_request' || needs.checks.result == 'success') && needs.build-tests.result == 'success' }}
|
||||
runs-on: ${{ github.event_name == 'merge_group' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -331,47 +406,49 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install a software Vulkan driver (lavapipe)
|
||||
# Preinstalled on the self-hosted runner, which has no sudo.
|
||||
if: runner.environment == 'github-hosted'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
# Each shard runs the archive the Build tests job made.
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- name: Run tests
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GOLDENS: ${{ needs.changes.outputs.goldens }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
|
||||
# The robustness golden seeds a pull request skips: each takes one to twenty minutes under
|
||||
# CI load, together about four fifths of the goldens' time. Pushes to main, the merge
|
||||
# queue and the nightly run every seed.
|
||||
SLOW_SEEDS: 81|45|20|24|95|02|57|40|18|48|32|46|85|61|77|34|14
|
||||
run: |
|
||||
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
|
||||
all=()
|
||||
# The boolean property tests draw new random inputs every run; they run only in the
|
||||
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
|
||||
filter=()
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
all=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
|
||||
# The boolean property tests draw new random inputs every run; they run only in the
|
||||
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
|
||||
filter=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
|
||||
elif [ "$GOLDENS" = true ]; then
|
||||
# A pull request that can change a robustness golden (tests/robustness/goldens.json)
|
||||
# also checks every seed but the slow ones, in the same run so they share its cores.
|
||||
filter=(--ignore-default-filter -E "default() | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))")
|
||||
fi
|
||||
junit=${CARGO_TARGET_DIR:-target}/nextest/ci/junit.xml
|
||||
rm -f "$junit"
|
||||
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
|
||||
# the build job's path, which is the same checkout path here.
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${all[@]}"
|
||||
- name: Robustness goldens (pull requests that change geometry)
|
||||
# Every seed of tests/robustness/goldens.json (each seed is a test), split over the shards;
|
||||
# pushes to main and the nightly run them in the step above.
|
||||
if: github.event_name == 'pull_request' && needs.changes.outputs.goldens == 'true'
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
|
||||
run: |
|
||||
if ! cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --ignore-default-filter --partition "slice:$SHARD/$SHARDS" --no-tests=pass \
|
||||
-E 'package(solidcraft-engine) & test(/^tests::robustness_goldens::/)'; then
|
||||
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
|
||||
echo "::error::ROBUSTNESS_GOLDENS_BLESS=\"why it changed\" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens:: (see docs/robustness.md)"
|
||||
run=(cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite)
|
||||
if ! "${run[@]}" --profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${filter[@]}"; then
|
||||
# A failed golden gets the hint to re-record it (junit.xml lists the failures).
|
||||
if [ -f "$junit" ] && tr '\n' ' ' < "$junit" | grep -q '<testcase name="tests::robustness_goldens::[^"]*"[^>]*>[[:space:]]*<failure'; then
|
||||
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
|
||||
echo "::error::ROBUSTNESS_GOLDENS_BLESS=\"why it changed\" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens:: (see docs/robustness.md)"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
- name: Upload test timings
|
||||
@@ -379,7 +456,7 @@ jobs:
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: junit-${{ matrix.shard }}
|
||||
path: target/nextest/ci/junit.xml
|
||||
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
action-matrix:
|
||||
@@ -390,7 +467,7 @@ jobs:
|
||||
name: Action matrix (${{ matrix.shard }}/16)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 55
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -432,7 +509,7 @@ jobs:
|
||||
name: Action matrix determinism
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -454,7 +531,7 @@ jobs:
|
||||
name: Property tests (many cases)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -477,8 +554,8 @@ jobs:
|
||||
doc-tests:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
if: needs.changes.outputs.doctests == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -489,14 +566,9 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh doc-tests
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: doc-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -531,7 +603,7 @@ jobs:
|
||||
name: Reference tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.oracle == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -542,14 +614,9 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh oracle
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: oracle
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -577,7 +644,7 @@ jobs:
|
||||
name: Windows D3D12 FXC shaders
|
||||
needs: changes
|
||||
if: needs.changes.outputs.windows == 'true'
|
||||
runs-on: windows-latest
|
||||
runs-on: blacksmith-8vcpu-windows-2025
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
WGPU_BACKEND: dx12
|
||||
@@ -605,6 +672,8 @@ jobs:
|
||||
name: Windows app startup
|
||||
needs: changes
|
||||
if: needs.changes.outputs.windows == 'true'
|
||||
# GitHub-hosted: the startup smoke needs an interactive desktop that presents frames; on
|
||||
# Blacksmith's Windows runners the window never presents ("no frame was presented").
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -627,17 +696,54 @@ jobs:
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
# On a run another label starts (every other job skipped), this job reports the result of the
|
||||
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
|
||||
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
|
||||
name: ci
|
||||
needs: [changes, checks, quick-compile, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
# To read the commit's other runs of this workflow.
|
||||
actions: read
|
||||
steps:
|
||||
- name: Report the commit's real result (a run another label started)
|
||||
if: github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.event.pull_request.head.sha }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
# The other runs of ci.yml for this commit. While one is still going, wait for it (at
|
||||
# most 90 minutes); then pass only when the newest one that ran its checks passed.
|
||||
deadline=$((SECONDS + 5400))
|
||||
while :; do
|
||||
runs=$(gh api "repos/$REPO/actions/workflows/ci.yml/runs?head_sha=$SHA&event=pull_request&per_page=100" \
|
||||
--jq "[.workflow_runs[] | select(.id != $RUN_ID)]")
|
||||
pending=$(jq '[.[] | select(.status != "completed")] | length' <<<"$runs")
|
||||
if [ "$pending" -eq 0 ] || [ "$SECONDS" -ge "$deadline" ]; then break; fi
|
||||
echo "Waiting for $pending run(s) of this commit to finish."
|
||||
sleep 30
|
||||
done
|
||||
# Runs with every job skipped (labels, before this job reported) are not a result.
|
||||
latest=$(jq -r '[.[] | select(.status == "completed" and .conclusion != "skipped")]
|
||||
| sort_by(.created_at) | last | "\(.conclusion) \(.html_url)"' <<<"$runs")
|
||||
echo "The commit's latest run: $latest"
|
||||
echo "A label started this run: \`ci\` repeats the commit's latest result ($latest)." >> "$GITHUB_STEP_SUMMARY"
|
||||
test "${latest%% *}" = success
|
||||
- name: Verify required checks
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci'
|
||||
env:
|
||||
CHANGES: ${{ needs.changes.result }}
|
||||
CHECKS: ${{ needs.checks.result }}
|
||||
NATIVE: ${{ needs.changes.outputs.native }}
|
||||
QUICK: ${{ needs.changes.outputs.quick }}
|
||||
QUICK_COMPILE: ${{ needs.quick-compile.result }}
|
||||
WINDOWS: ${{ needs.changes.outputs.windows }}
|
||||
ORACLE_RUN: ${{ needs.changes.outputs.oracle }}
|
||||
DOCTESTS: ${{ needs.changes.outputs.doctests }}
|
||||
CLIPPY: ${{ needs.clippy.result }}
|
||||
BUILD: ${{ needs.build-tests.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
@@ -648,10 +754,16 @@ jobs:
|
||||
run: |
|
||||
test "$CHANGES" = success
|
||||
test "$CHECKS" = success
|
||||
# An unapproved pull request: the heavy jobs are skipped on purpose.
|
||||
if [ "$QUICK" = true ]; then
|
||||
test "$QUICK_COMPILE" = success
|
||||
fi
|
||||
if [ "$NATIVE" = true ]; then
|
||||
test "$CLIPPY" = success
|
||||
test "$BUILD" = success
|
||||
test "$TESTS" = success
|
||||
fi
|
||||
if [ "$DOCTESTS" = true ]; then
|
||||
test "$DOCS" = success
|
||||
fi
|
||||
if [ "$ORACLE_RUN" = true ]; then
|
||||
@@ -661,11 +773,17 @@ jobs:
|
||||
test "$WINDOWS_FXC" = success
|
||||
test "$WINDOWS_STARTUP" = success
|
||||
fi
|
||||
- name: Note a carried result
|
||||
if: needs.changes.outputs.carried != ''
|
||||
env:
|
||||
CARRIED: ${{ needs.changes.outputs.carried }}
|
||||
run: |
|
||||
echo "This push only merges main into a change that already passed; the heavy checks were not repeated. Passing run: $CARRIED" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
wasm:
|
||||
name: Compile WebAssembly (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -681,7 +799,7 @@ jobs:
|
||||
riscv64-gates:
|
||||
name: Compile RISC-V (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -14,6 +14,11 @@ name: Docker
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# The image holds the web app only: documentation, test data and the GitHub bots' scripts can't
|
||||
# change it, so a push touching only those builds nothing (edge stays at the last code change).
|
||||
# ATTRIBUTION.md is the exception among the Markdown files: the app embeds it (Help, About).
|
||||
# The last matching pattern wins.
|
||||
paths: ["**", "!docs/**", "!**/*.md", "ATTRIBUTION.md", "!tests/**", "!scripts/claim/**", "!scripts/triage/**"]
|
||||
release:
|
||||
types: [published]
|
||||
pull_request:
|
||||
|
||||
@@ -6,6 +6,18 @@ name: Generated files
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# THIRD-PARTY-LICENSES.txt follows the manifests, the lockfile, the vendored crates' licence
|
||||
# files and the fonts' licences; docs/features.md follows the command registry (crates/engine),
|
||||
# read through solidcraft-cli. Other pushes regenerate nothing.
|
||||
paths:
|
||||
- "Cargo.lock"
|
||||
- "**/Cargo.toml"
|
||||
- "vendor/**"
|
||||
- "assets/fonts/**"
|
||||
- "crates/engine/**"
|
||||
- "apps/solidcraft-cli/**"
|
||||
- "xtask/**"
|
||||
- ".github/workflows/generated.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -6,6 +6,8 @@ name: Pages
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# The site is the book and the web app: test data and the GitHub bots' scripts change neither.
|
||||
paths-ignore: ["tests/**", "scripts/claim/**", "scripts/triage/**"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
|
||||
@@ -27,8 +27,10 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
|
||||
## Never crash
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
|
||||
(which denies it instead), have no safe API: two functions in
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
|
||||
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
|
||||
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
|
||||
and calls through its function pointers), two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
|
||||
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers) and the Windows-only
|
||||
@@ -48,6 +50,8 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
|
||||
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
|
||||
- **Where tests go:** a regression test goes into the existing test file for its area (booleans, blends,
|
||||
shell, sweep, STEP, naming…), never a new file named after the bug (#3226).
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
@@ -96,12 +100,13 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
|
||||
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
|
||||
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
|
||||
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
|
||||
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
|
||||
generated reference data; check `git ls-files` before pushing.
|
||||
- **Publishing**: the repository is github.com/tangency-labs/solidcraft. Changes land through
|
||||
**pull requests** and the **merge queue**: push a branch and open a PR. The heavy CI jobs start
|
||||
when a maintainer adds `approved-for-ci` after reviewing the code. Approved PRs go into the queue
|
||||
with `gh pr merge N -R tangency-labs/solidcraft --squash --auto`; the queue runs the full suite
|
||||
on each group before it lands on `main`. Keep each PR to one topic and one issue, and never
|
||||
force-push `main`. Never commit `plan/`, build dirs (`target*/`) or generated reference data;
|
||||
check `git ls-files` before pushing.
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
@@ -120,22 +125,34 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- Use a small public-safe example and check for private designs, paths, account details or
|
||||
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
|
||||
recording; do not commit generated screenshots or videos just to provide review evidence.
|
||||
- Throughout the work and before handoff, check current upstream commits and the relevant
|
||||
PR's merge status. Keep your branch current with upstream merges, resolve conflicts without
|
||||
discarding others' work, and re-run validation when integration changes the result. Use
|
||||
`git pull --rebase origin main` before pushing as above; never force-push. Report the checked
|
||||
upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open,
|
||||
keep monitoring it while the task is active and make its pending status clear at handoff;
|
||||
do not claim it merged without checking.
|
||||
- Throughout the work and before handoff, check the relevant PR's merge status. Don't push to
|
||||
an open PR only to catch up with `main` (no merge of `main`, no Update branch, no rebase for
|
||||
that alone): every push restarts the whole CI run, and `main`'s own run catches what lands in
|
||||
between, to be fixed forward. When you push a change anyway, or GitHub reports a conflict,
|
||||
rebase onto the latest `main` (push your own branch with `--force-with-lease`); never merge
|
||||
`main` into a PR. Resolve conflicts without discarding others' work; never force-push `main`.
|
||||
Don't re-run CI on an unchanged commit: a real failure fails again. Re-run only a job that
|
||||
runner trouble cancelled, with "Re-run failed jobs". Report the PR state you checked and any
|
||||
conflicts resolved. If a PR remains open, keep monitoring it while the task is active and
|
||||
make its pending status clear at handoff; do not claim it merged without checking.
|
||||
|
||||
## Before you start: look at what is already open
|
||||
- Work with an authenticated GitHub CLI (`gh auth status`) and `git`; the checks below need both.
|
||||
- Pull the whole list, not one search: `gh pr list -R tangency-labs/solidcraft --state open --limit 500`
|
||||
(drafts included) and `gh issue list -R tangency-labs/solidcraft --state open --limit 500`. Look for
|
||||
work on the same issue, the same feature under another number, and PRs that change the files
|
||||
you are about to change (`gh pr list ... --json number,title,files`).
|
||||
- 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.
|
||||
|
||||
## Claim = a draft PR
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- 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,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. A comment or file edit alone does not replace the draft PR.
|
||||
- After opening the draft PR, comment `/claim` on the issue so the bot records the claimant and
|
||||
adds the `in progress` label. **Do both:** the draft PR establishes ownership and `/claim` marks
|
||||
the issue as taken. Reference `#N` in the PR so the bot can find it.
|
||||
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
|
||||
labels the issue `in progress` (or says on your PR who holds it). Reference `#N` in the PR title
|
||||
so the bot can find it. Don't claim by editing files; comment `/claim` on the issue only to stake
|
||||
a claim before you have a branch.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
|
||||
|
||||
@@ -28,6 +28,13 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| docs/screenshots/class-a-crown.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch crowned with `surface.patch crown=0.3` over a saddle rim (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/0.png | SolidCraft contributors | original Sample Plate from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/1.png | SolidCraft contributors | original Sheet Metal Bracket from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/2.png | SolidCraft contributors | original Hinge Assembly from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/3.png | SolidCraft contributors | original Plastic Enclosure from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/4.png | SolidCraft contributors | original Bolted Flange from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/5.png | SolidCraft contributors | original Vent Plate from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
|
||||
| assets/samples/exports/scripts.json | SolidCraft contributors | SHA-256 fingerprints of the original sample scripts, generated with the shipped thumbnails | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
|
||||
@@ -45,6 +52,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/revolve.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sheet-metal.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/tool-preview.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/tool-preview.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
|
||||
| assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
|
||||
| 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 |
|
||||
@@ -88,6 +96,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/source/navigation.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/cs.json | potuzai | Czech translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/da.json | SolidCraft contributors | Original Danish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
@@ -96,11 +105,13 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ms.json | A.Hakim (a-hakim) | Malay translation of the English message catalog | MIT OR Apache-2.0 |
|
||||
| assets/locales/pl.json | cyryllo | Polish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/pt-BR.json | DuBearte | Brazilian Portuguese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/tr.json | macrulz54 | Turkish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/vi.json | vandang890615 | Vietnamese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
@@ -269,6 +280,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/svg/model/pattern_rect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/perforate.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/pipe.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/silhouette.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/placeholder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/plane_angle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
@@ -408,6 +420,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/tools/author/lib.py | SolidCraft contributors | icon authoring script (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
|
||||
|
||||
| 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 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
@@ -469,7 +482,7 @@ copied into SolidCraft.
|
||||
|---|---|---|---|
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on; reuse of an operand's meshes (vendor/README.md) |
|
||||
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters; surface second derivatives (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
|
||||
@@ -1,183 +1,3 @@
|
||||
# SolidCraft — instructions for agents
|
||||
@AGENTS.md
|
||||
|
||||
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
|
||||
sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Projects published by
|
||||
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
|
||||
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
|
||||
|
||||
## Product name
|
||||
SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`,
|
||||
`.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables,
|
||||
settings and recovery folders, the MCP server name, docs, CI and packaging. The old name
|
||||
survives only in the explicit compatibility shims listed in `docs/compatibility.md`; don't add
|
||||
new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pages
|
||||
`/solidcraft/`; do not add permanent old-name Pages aliases.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
|
||||
and the relevant `plan/architecture.md` section.
|
||||
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
|
||||
is rejected for licence reasons).
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
|
||||
unless it lists the decision as the owner's.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
|
||||
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
|
||||
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
|
||||
and calls through its function pointers), two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
|
||||
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers) and the Windows-only
|
||||
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
|
||||
test of the window without OS decorations).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
|
||||
returns an error ("not supported yet: …"); it never panics.
|
||||
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
|
||||
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
|
||||
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
|
||||
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
|
||||
panic restores the design and reports `EngineError::Internal`.
|
||||
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
|
||||
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
|
||||
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
|
||||
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
|
||||
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
|
||||
general CAD conventions and user feedback. Reference tests use analytic values and open-source
|
||||
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
|
||||
materials. File formats come from public specifications.
|
||||
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
|
||||
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
|
||||
measurements, command lists, ids or files produced with them.
|
||||
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
|
||||
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
|
||||
runs FreeCAD as a separate program through its public scripting API to compute reference values;
|
||||
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
|
||||
artwork from commercial CAD products — ever.** Every UI icon is drawn in collected code
|
||||
(`assets/icons/ui/source/`, compiled through `crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
|
||||
Breaking this rule is the most serious mistake you can make in this repo.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
|
||||
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
|
||||
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
|
||||
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
|
||||
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
|
||||
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
|
||||
clicks (`ui.start`) start interactive tools and dialogs, which then run commands.
|
||||
- **Layering** is enforced by `cargo xtask layers`: L0 `geom` → L1 `sketch`, `kernel`, `render`
|
||||
→ L2 `doc` → L3 `io` → L4 `engine` → L5 `ui-egui` → apps. Nothing below L5 depends on
|
||||
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.start(id)`. Colours come from `theme::Tokens`.
|
||||
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
|
||||
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
|
||||
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
|
||||
camera input (a test rejects it).
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
|
||||
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
|
||||
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
|
||||
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
|
||||
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
|
||||
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
|
||||
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
|
||||
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
|
||||
generated reference data; check `git ls-files` before pushing.
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
## Showing and following through on changes
|
||||
|
||||
- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior,
|
||||
issues and PRs must include actual descriptive screenshots or a short recording from a
|
||||
small reproduction. This includes kernel, geometry, assembly and backend changes whose
|
||||
effects can be shown in the app. Coding agents should run the app and capture this evidence
|
||||
themselves; do not ask a human for evidence the agent can produce. Show the actual failure
|
||||
or result, preferably before and after, with brief reproduction steps, expected/actual
|
||||
behavior and annotations that point out the relevant detail.
|
||||
- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible
|
||||
behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate
|
||||
the point. Changes with no useful observable behavior do not need screenshot or video evidence.
|
||||
- Use a small public-safe example and check for private designs, paths, account details or
|
||||
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
|
||||
recording; do not commit generated screenshots or videos just to provide review evidence.
|
||||
- Throughout the work and before handoff, check current upstream commits and the relevant
|
||||
PR's merge status. Keep your branch current with upstream merges, resolve conflicts without
|
||||
discarding others' work, and re-run validation when integration changes the result. Use
|
||||
`git pull --rebase origin main` before pushing as above; never force-push. Report the checked
|
||||
upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open,
|
||||
keep monitoring it while the task is active and make its pending status clear at handoff;
|
||||
do not claim it merged without checking.
|
||||
|
||||
## Claim = a draft PR
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
|
||||
labels the issue `in progress` (or says on your PR who holds it). Don't claim by editing files.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
|
||||
and wait while it is over 24; build with `nice`.
|
||||
|
||||
## Design file format
|
||||
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
|
||||
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
|
||||
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
|
||||
works out the number (the next one while anything is pending), and the release folds pending
|
||||
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
|
||||
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
|
||||
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
|
||||
version bumps solely from commit prefixes, overwrite published versions, or push release tags
|
||||
before the draft is verified.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`). The first
|
||||
request on a connection carries the launch's token as `"token"`: read it from
|
||||
`~/.local/share/solidcraft/control/PORT.token`, or start the app with
|
||||
`SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that.
|
||||
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
|
||||
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
|
||||
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
|
||||
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
|
||||
`ui.render` or `solidcraft-cli snapshot`.
|
||||
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`,
|
||||
`solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
|
||||
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
|
||||
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
|
||||
values never come from a commercial CAD product.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
|
||||
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
|
||||
workflow; never regenerate them in a PR.
|
||||
|
||||
## Lists many PRs add to
|
||||
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.
|
||||
The agent instructions for this repo live in AGENTS.md; edit that file only.
|
||||
|
||||
@@ -72,7 +72,7 @@ desktop integration and how to distinguish an old executable on `PATH`.
|
||||
|
||||
## How the code is laid out
|
||||
|
||||
The [README](README.md#architecture) has the layer table. The short version:
|
||||
The [architecture page](docs/architecture.md) has the layer table. The short version:
|
||||
|
||||
- `crates/geom`: vectors, planes, meshes, measures.
|
||||
- `crates/sketch`: our own constraint solver, plus profile finding.
|
||||
@@ -183,25 +183,27 @@ depend on wall-clock timing.
|
||||
**Ready pull requests test what they touch.** CI maps changed files to workspace packages and
|
||||
runs Clippy, the test build and tests for those and their dependents. The
|
||||
[package selector](scripts/ci/affected.py) and [CI workflow](.github/workflows/ci.yml) define
|
||||
which changes require native checks; documentation, packaging and bot-script-only PRs can run
|
||||
repository checks only. Workspace-wide changes, merge-queue groups, nightly runs and Run
|
||||
workflow select the full workspace. A push to `main` reuses a successful merge-queue run for
|
||||
the same commit; otherwise it runs the full suite.
|
||||
which changes require native checks; documentation, packaging, other-workflow and bot-script-only
|
||||
changes run the repository checks only, on pull requests and on `main`. Workspace-wide changes,
|
||||
merge-queue groups, nightly runs and Run workflow select the full workspace. A push to `main`
|
||||
reuses a successful merge-queue run for the same commit, and runs only the repository checks
|
||||
when no code changed since the last push to `main` that passed; otherwise it runs the full
|
||||
suite. Doc tests, reference tests and the Windows jobs run on `main` and nightly only.
|
||||
|
||||
**CI shards the tests.** One job builds them into a cargo-nextest archive; two shards run on
|
||||
pull requests and four during full validation. Every test runs in its own process: don't rely
|
||||
on state left by another test.
|
||||
Pull requests skip a few heavy tests (one to four minutes each), which run in full validation
|
||||
Pull requests skip a few heavy tests (one to twelve minutes each), which run in full validation
|
||||
(the merge queue, nightly, manual runs and pushes to `main` not already tested by the queue).
|
||||
Check that validation after merging. They are listed in
|
||||
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
|
||||
|
||||
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
|
||||
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all seeds),
|
||||
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all but the slowest, `SLOW_SEEDS` in `ci.yml`),
|
||||
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
|
||||
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
|
||||
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, and the `boolean_props` property tests
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, `tspline::tests::hostile_tsplines_never_panic`, and the `boolean_props` property tests
|
||||
(these run only in the nightly "Property tests" job: their inputs are random, so main skips them too)
|
||||
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
|
||||
|
||||
@@ -285,6 +287,11 @@ and a fresh CI run:
|
||||
2. Branch from `main` and keep the PR to one topic.
|
||||
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
|
||||
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
|
||||
Heavy CI runs after review: a pull request gets the formatting and repository checks and a
|
||||
quick compile (`cargo check`) when you push. Once a maintainer has reviewed it they add the
|
||||
`approved-for-ci` label when the PR is ready to merge, which runs the full CI (build, tests,
|
||||
goldens, Clippy); every push after that runs the full set again. Maintainers merge approved
|
||||
PRs in groups by area, so PRs that touch the same code meet a conflict at most once.
|
||||
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
|
||||
the expected ones).
|
||||
|
||||
@@ -309,12 +316,15 @@ Changes with no useful observable behavior do not need screenshot or video evide
|
||||
small example without private designs or sensitive details; attach the media or link to an
|
||||
accessible recording rather than committing generated review artifacts.
|
||||
|
||||
Keep checking upstream commits and the relevant PR's merge status throughout the work and
|
||||
before handoff. Integrate upstream merges, resolve conflicts without losing others' changes,
|
||||
and validate the integrated result. Before pushing, run `git pull --rebase origin main`, re-run
|
||||
CI if it brought changes, and push without force. Report the upstream/PR state you checked,
|
||||
any conflicts resolved and validation results. While a task remains active, follow an open
|
||||
PR through its status changes; at handoff, clearly identify anything still awaiting merge.
|
||||
Keep an eye on your PR's status, but don't push to it only to catch up with `main` (no merge of
|
||||
`main`, no Update branch button, no rebase for that alone): every push restarts the whole CI
|
||||
run for everyone waiting behind it, and `main`'s own run catches what lands in between. When you
|
||||
push a change anyway, or GitHub reports a conflict, rebase onto the latest `main` (push your
|
||||
branch with `--force-with-lease`) or merge `main` in once, and resolve conflicts without losing
|
||||
others' changes. Don't re-run CI on an unchanged commit either: a real failure fails again.
|
||||
Re-run only a job that runner trouble cancelled, with "Re-run failed jobs". While a task remains
|
||||
active, follow an open PR through its status changes; at handoff, clearly identify anything
|
||||
still awaiting merge.
|
||||
|
||||
Much of SolidCraft so far was written by AI coding agents (Claude) working in parallel, each
|
||||
owning one area, with a human setting direction and reporting bugs. Their working notes are in
|
||||
@@ -359,7 +369,21 @@ In order of how much they'd help users:
|
||||
3. **Drawings, CAM and mesh tools.** These are out of scope for now (see
|
||||
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
|
||||
|
||||
## Updating the generated feature list
|
||||
|
||||
After adding or renaming commands, run `cargo xtask features`. The registry is the
|
||||
source of truth for `docs/features.md`; do not hand-edit the generated rows.
|
||||
The generator sorts commands by ID and omits global and per-section counters,
|
||||
so additions to unrelated areas no longer rewrite shared summary lines.
|
||||
|
||||
If two branches still conflict in this file (for example, adjacent additions in
|
||||
the same area), first resolve the command registry and source conflicts, then run
|
||||
`cargo xtask features` again and stage the regenerated file. Do not use Git's
|
||||
`union` merge driver: it can silently retain stale or duplicate command rows.
|
||||
The generator tests cover stable ordering and additions that leave existing lines
|
||||
unchanged. The initial switch to sorted rows is a one-time larger diff.
|
||||
|
||||
## Licence
|
||||
|
||||
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
|
||||
of SolidCraft. SolidCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
|
||||
of SolidCraft. SolidCraft isn't affiliated with any CAD vendor; product names are trademarks of their owners (see NOTICE).
|
||||
|
||||
@@ -813,6 +813,17 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chardetng"
|
||||
version = "0.1.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"encoding_rs",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -1164,6 +1175,12 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.36.2"
|
||||
@@ -3503,6 +3520,26 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
|
||||
dependencies = [
|
||||
"ref-cast-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast-impl"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
@@ -3730,6 +3767,31 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -3849,6 +3911,17 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
@@ -3862,6 +3935,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
@@ -4078,7 +4162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4109,11 +4193,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -4124,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4133,7 +4217,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4150,11 +4234,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-io",
|
||||
@@ -4167,7 +4253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4176,7 +4262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4193,12 +4279,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
"cadmpeg-registry",
|
||||
"chardetng",
|
||||
"encoding_rs",
|
||||
"gltf",
|
||||
"log",
|
||||
"quick-xml",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
@@ -4213,7 +4302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4233,7 +4322,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4242,7 +4331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4256,7 +4345,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4267,11 +4356,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4286,6 +4378,7 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"solidcraft-space-mouse",
|
||||
"tiny-skia",
|
||||
"toml",
|
||||
"ttf-parser",
|
||||
@@ -4296,7 +4389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5826,7 +5919,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -36,6 +36,9 @@ proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
# command parameter and result schemas
|
||||
schemars = "1.2"
|
||||
serde_path_to_error = "0.1"
|
||||
log = "0.4"
|
||||
sha2 = "0.10"
|
||||
moxcms = { version = "0.8.1", default-features = false, features = ["extended_range"] }
|
||||
@@ -55,6 +58,11 @@ rayon = "1.12"
|
||||
# packages (3MF)
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.41"
|
||||
# STEP text in a legacy encoding (GBK, Shift_JIS, …) instead of Part 21 escapes (#2344): detect
|
||||
# and decode it. Both MIT OR Apache-2.0 (encoding_rs adds BSD-3-Clause data); encoding_rs is
|
||||
# already in the tree through cadmpeg-codec-step.
|
||||
chardetng = "0.1"
|
||||
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"] }
|
||||
@@ -123,6 +131,9 @@ incremental = false
|
||||
|
||||
[profile.ci.package."*"]
|
||||
opt-level = 2
|
||||
# No line tables for dependencies: they were over a third of the test archive every CI shard
|
||||
# downloads. Our own crates keep theirs, so a backtrace still names file:line in them.
|
||||
debug = false
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -29,5 +29,8 @@ for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL
|
||||
per-file list is in ATTRIBUTION.md.
|
||||
|
||||
SolidCraft is an independent project and is not affiliated with, sponsored by or endorsed by
|
||||
Autodesk, Inc. Autodesk and Fusion are trademarks of Autodesk, Inc.; they are used here only to
|
||||
describe compatibility goals.
|
||||
Autodesk, Dassault Systèmes, PTC, Siemens, Blender or any other CAD vendor. Fusion, AutoCAD,
|
||||
Inventor, Alias and Tinkercad are trademarks of Autodesk, Inc.; SolidWorks is a trademark of
|
||||
Dassault Systèmes; Onshape and Creo are trademarks of PTC; Blender is a trademark of the Blender
|
||||
Foundation. Product names appear only to describe compatibility, such as the mouse navigation
|
||||
presets in Preferences › Navigation and file formats from public specifications.
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<a href="https://solidcraft.io/download">Download</a> ·
|
||||
<a href="https://solidcraft.io/app">Try it in the browser</a> ·
|
||||
<a href="https://docs.solidcraft.io/">Documentation</a> ·
|
||||
<a href="https://www.blacksmith.sh"><img src="https://img.shields.io/badge/CI-Blacksmith-000000?logo=githubactions&logoColor=white" alt="CI powered by Blacksmith" /></a>
|
||||
<a href="https://discord.gg/FtU4NQNVDH">Discord</a> ·
|
||||
<a href="https://github.com/bherbruck/solidcore">SolidCore</a>
|
||||
</p>
|
||||
@@ -46,30 +47,85 @@
|
||||
|
||||
## Download
|
||||
|
||||
- **Windows:** download and run the [installer][windows-msi].
|
||||
Also for [ARM64][windows-arm64-msi] and [32-bit][windows-x86-msi] PCs, or as a
|
||||
[portable zip][windows-portable] that runs without installing.
|
||||
With [WinGet](https://learn.microsoft.com/windows/package-manager/winget/):
|
||||
`winget install bherbruck.SolidCraft`.
|
||||
- **macOS:** open the [disk image][macos-dmg] and drag SolidCraft into Applications
|
||||
(Apple silicon and Intel).
|
||||
- **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it.
|
||||
Or install the [.deb][linux-deb] (Ubuntu, Debian, Mint) or [.rpm][linux-rpm] (Fedora,
|
||||
openSUSE), or use [Flatpak](https://docs.solidcraft.io/guide/getting-started.html#linux-flatpak).
|
||||
With Snap: `sudo snap install solidcraft` ([Snap Store][snap-store], or the [.snap file][linux-snap]).
|
||||
For ARM64: [AppImage][linux-arm64-appimage], [.deb][linux-arm64-deb], [.rpm][linux-arm64-rpm].
|
||||
- **Windows:** the [installer][windows-msi], or `winget install bherbruck.SolidCraft`.
|
||||
- **macOS:** the [disk image][macos-dmg] (Apple silicon and Intel).
|
||||
- **Linux:** the [AppImage][linux-appimage], or `sudo snap install solidcraft` ([Snap Store][snap-store]).
|
||||
- **Web:** [try it in your browser][web-app], no install.
|
||||
|
||||
The downloads aren't signed yet. On Windows, if SmartScreen warns you, choose **More info**, then
|
||||
**Run anyway**. On macOS, the first time, right-click SolidCraft in Applications and choose **Open**.
|
||||
ARM64 and 32-bit builds, portable zip, .deb, .rpm, Flatpak and the other packages are on the
|
||||
[downloads page](docs/downloads.md). The downloads aren't signed yet: on Windows choose
|
||||
**More info**, then **Run anyway** in SmartScreen; on macOS right-click SolidCraft and choose **Open**
|
||||
the first time. On SolidCraft 0.11 to 0.14, download once by hand; see
|
||||
[updating](https://docs.solidcraft.io/guide/updates.html#updating-from-011-to-014).
|
||||
|
||||
**On SolidCraft 0.11 to 0.14?** Its **Update now** can't install 0.15.0 or later, because the
|
||||
releases moved to a new address. Download the new version once from the links above; after that,
|
||||
updating from inside SolidCraft works again ([details](https://docs.solidcraft.io/guide/updates.html#updating-from-011-to-014)).
|
||||
## What works
|
||||
|
||||
Older versions, checksums and other formats are on the [releases page](https://github.com/tangency-labs/solidcraft/releases).
|
||||
Install details, upgrading from earlier builds and building from source are in
|
||||
[Getting started](https://docs.solidcraft.io/guide/getting-started.html).
|
||||
Early but usable for simple parts: 75 of 76 public reference parts match their analytic or
|
||||
FreeCAD-computed volume, area, centre of mass and bounding box ([oracle](docs/oracle.md)); the
|
||||
other is a tracked bug.
|
||||
|
||||
- **Sketches and constraints:** lines, arcs, splines, text and more, solved by our own constraint solver, with parameter-driven dimensions.
|
||||
- **Features:** extrude, revolve, sweep, loft, fillet, chamfer, shell, draft, holes, threads, patterns, mirror, split and combine on an editable parametric timeline.
|
||||
- **Parameters and configurations** with unit-aware expressions.
|
||||
- **Assemblies** with joints, motion studies, contact sets, interference and mass properties.
|
||||
- **Sheet metal** with flanges, hems, fold/unfold and flat patterns, and plastic enclosure features.
|
||||
- **Files:** STEP, IGES, 3MF, STL, OBJ and glTF in and out where supported, URDF export, `.solidcraft` designs.
|
||||
- **Desktop app, browser app, headless CLI and MCP server**, all running the same commands.
|
||||
|
||||
The full list is in [docs/capabilities.md](docs/capabilities.md) and [docs/features.md](docs/features.md);
|
||||
status and plans are in [ROADMAP.md](ROADMAP.md). See also [what people built](docs/showcase.md),
|
||||
[3D mouse support](docs/3d-mouse.md) and the [architecture](docs/architecture.md).
|
||||
|
||||
## Build from source
|
||||
|
||||
```sh
|
||||
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solidcraft-cli -- commands # the command registry
|
||||
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo xtask ci # fmt, clippy, tests, icons, assets, layers, wasm
|
||||
```
|
||||
|
||||
More commands, the Windows cross-build and the browser build are in [docs/building.md](docs/building.md).
|
||||
|
||||
## AI agents (MCP)
|
||||
|
||||
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server. Headless it
|
||||
runs its own design session, or with `--connect` it drives the running app so you can watch.
|
||||
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Tools, access rules and an
|
||||
example session: [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
## Scripts
|
||||
|
||||
A script is a JSON list of the same commands the UI runs, executed with `solidcraft-cli run`.
|
||||
Format and example: [docs/scripts.md](docs/scripts.md). Pages can also be driven through the
|
||||
[browser command API](docs/browser-api.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Help is welcome. [CONTRIBUTING.md](CONTRIBUTING.md) covers how to build and test, the code map,
|
||||
the clean-room and licence rules, and where help is wanted most. Open work is in
|
||||
[GitHub issues](https://github.com/tangency-labs/solidcraft/issues); chat is on
|
||||
[Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
## Sponsors
|
||||
|
||||
[](https://www.blacksmith.sh)
|
||||
|
||||
SolidCraft's continuous integration runs on [Blacksmith](https://www.blacksmith.sh), which
|
||||
sponsors its CI minutes.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT OR Apache-2.0, at your option. Developed under the
|
||||
[clean-room contribution rules](AGENTS.md); third-party code and assets retain their
|
||||
documented licences and attribution.
|
||||
|
||||
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
|
||||
conventions and user feedback. Reference tests use analytic values and open-source tools.
|
||||
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk, Dassault Systèmes, PTC,
|
||||
Siemens or any other CAD vendor. Fusion, AutoCAD, Inventor, SolidWorks, Onshape, Creo, Blender and
|
||||
Tinkercad are trademarks of their respective owners, named only to describe compatibility
|
||||
(see [NOTICE](NOTICE)).
|
||||
|
||||
[badge-windows]: https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0yIDJoOS41djkuNUgyek0xMi41IDJIMjJ2OS41aC05LjV6TTIgMTIuNWg5LjVWMjJIMnpNMTIuNSAxMi41SDIyVjIyaC05LjV6Ii8%2BPC9zdmc%2B
|
||||
[badge-macos]: https://img.shields.io/badge/macOS-Download-333333?style=for-the-badge&logo=apple&logoColor=white
|
||||
@@ -77,254 +133,7 @@ Install details, upgrading from earlier builds and building from source are in
|
||||
[badge-snap]: https://img.shields.io/badge/Snap-Snap_Store-82BEA0?style=for-the-badge&logo=snapcraft&logoColor=white
|
||||
[badge-web]: https://img.shields.io/badge/Web-Try_in_browser-2EA44F?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0yIDEyaDIwTTEyIDJhMTUgMTUgMCAwIDEgMCAyME0xMiAyYTE1IDE1IDAgMCAwIDAgMjAiLz48L3N2Zz4%3D
|
||||
[windows-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x64.msi
|
||||
[windows-arm64-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-arm64.msi
|
||||
[windows-x86-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x86.msi
|
||||
[windows-portable]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x64-portable.zip
|
||||
[macos-dmg]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-macos-universal.dmg
|
||||
[linux-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
|
||||
[linux-deb]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.deb
|
||||
[linux-rpm]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.rpm
|
||||
[linux-arm64-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.AppImage
|
||||
[linux-arm64-deb]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.deb
|
||||
[linux-arm64-rpm]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-aarch64.rpm
|
||||
[linux-snap]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.snap
|
||||
[snap-store]: https://snapcraft.io/solidcraft
|
||||
[web-app]: https://solidcraft.io/app
|
||||
|
||||
**Documentation:** the [SolidCraft book](https://docs.solidcraft.io/) (user guide,
|
||||
developer guide and command reference; sources in [docs/book](docs/book)).
|
||||
|
||||
**Community:** chat with users and contributors on [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
**Status: early but usable for simple parts.** 75 of 76 public reference parts
|
||||
match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box
|
||||
([docs/oracle.md](docs/oracle.md)); the other is a tracked bug.
|
||||
These checks do not establish complete geometric or workflow equivalence. The commands SolidCraft has
|
||||
are listed by area in [docs/features.md](docs/features.md); status and plans are in [ROADMAP.md](ROADMAP.md).
|
||||
Open work and known bugs are in [GitHub issues](https://github.com/tangency-labs/solidcraft/issues),
|
||||
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Built with SolidCraft
|
||||
|
||||
<a href="https://github.com/0xlillith/solidcraft-rover"><img src="docs/screenshots/rover-build.gif" alt="SELENE, a scientific rover by 0xlillith, assembling itself as the timeline plays through its 3,323 steps in SolidCraft: tub, wheels, electronics, suspension, sampling arm, solar wings, dish and camera turret" loading="lazy"></a>
|
||||
|
||||
*SELENE, a scientific rover built entirely through SolidCraft's MCP server by
|
||||
[@0xlillith](https://github.com/0xlillith/solidcraft-rover): 3,323 editable timeline steps that
|
||||
rebuild with no errors, replayed here at 2× speed.
|
||||
[Full video](https://github.com/tangency-labs/solidcraft/raw/refs/heads/main/docs/screenshots/rover-build.mp4)
|
||||
· [download the editable model](https://github.com/0xlillith/solidcraft-rover/releases/tag/v1.0.0).*
|
||||
|
||||
## What works
|
||||
|
||||
- **Current-view DXF export** from File → Export Current View as DXF, with hidden edges
|
||||
omitted or dashed, shown body placements and the current camera. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
|
||||
arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry,
|
||||
projection, trimming, fillets and chamfers, including corners at the origin.
|
||||
New face attachments retain supported construction identities and chosen occurrences;
|
||||
missing sources expose an explicit plane repair workflow. See [face attachments](docs/face-attachments.md).
|
||||
Explicitly closed fit splines and closed associative offsets use periodic C2 interpolation.
|
||||
Supported convex line-and-arc offsets retain their identity when an inset consumes corner
|
||||
arcs, and linked extrusions follow the current boundary. Breaking the association freezes
|
||||
the boundary and keeps existing extrusions usable.
|
||||
Curve drawing and picking adapt together to zoom and display scale, including small circles
|
||||
at Fit and sketches in placed components.
|
||||
- **Constraints and dimensions** solved by our own solver. Points the constraints fix relative
|
||||
to known geometry are placed exactly by construction (loci intersections and rigid-cluster
|
||||
decomposition, after Owen 1991 and Fudos–Hoffmann 1997); Levenberg–Marquardt solves the rest,
|
||||
and the rank of its Jacobian gives the remaining degrees of freedom. Constraints: coincident,
|
||||
point on curve, horizontal/vertical, parallel, perpendicular, tangent, curvature (G2), equal,
|
||||
concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
|
||||
point-to-line, radius, diameter, angle, arc sweep, arc length and slot length dimensions driven
|
||||
by parameters. Over-constraining edits are rejected; fully constrained geometry turns black.
|
||||
Dragging solves the sketch every frame and rebuilds the model once, when the drag ends.
|
||||
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
|
||||
step; with Sketch Dimension it's D, click, type, Enter. Fit keeps sketch geometry clear of the palette and navigation controls.
|
||||
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
|
||||
Newly captured complete adaptive boundaries retain their outer and hole source identities
|
||||
when spline span counts change. Partial or ambiguous boundaries do not qualify.
|
||||
- **Features** on a parametric timeline: extrude, revolve, sweep, loft, fillet/chamfer,
|
||||
shell, draft, holes, threads, patterns, mirror, split, combine, move and construction geometry.
|
||||
The timeline rebuilds incrementally; features can be edited, suppressed, renamed, reordered
|
||||
and deleted. Supported geometry and current limitations are described in [ROADMAP.md](ROADMAP.md).
|
||||
New revolves preserve symmetric and two-sided angular placement across reversed axes;
|
||||
older designs retain their saved convention when reopened or edited.
|
||||
- **Parameters and configurations** with unit-aware expressions (`2 * width + 5 mm`,
|
||||
`angle / 2`), ordered rules, feature suppression and per-row body materials/appearances.
|
||||
- **Assemblies** with nested components and occurrences, joints, motion studies, contact sets,
|
||||
interference and material-aware mass properties. Recorded body transfers preserve modelling
|
||||
history and world placement; 3D sketch wires retain their coordinate frames through edits.
|
||||
- **Sheet metal** with flanges, hems, fold/unfold/refold, material cutouts, flat patterns,
|
||||
retained shallow prismatic pockets, exact line/arc/circle DXF outlines and bend tables;
|
||||
plastic enclosure features and rules. Pocket depth and side survive unfold/refold and appear
|
||||
separately from through-hole outlines in the flat preview and DXF.
|
||||
- **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads;
|
||||
plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved
|
||||
provisional picture from warm geometry caches while rebuilding before editing; caches never
|
||||
replace the parametric design. Export to STEP (AP242 with body names, colours and
|
||||
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
|
||||
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
|
||||
assemblies with joints as URDF for robot simulators (docs/urdf.md);
|
||||
3MF, STL, OBJ and glTF 2.0 (`.glb`/`.gltf`, its node tree as components and its materials as
|
||||
appearances) import as mesh bodies (they render, measure, move and export; solid features
|
||||
need B-rep bodies). The `mesh.convert` command reconstructs supported closed constant-section parts
|
||||
as parametric solids with planar and circular cylindrical faces;
|
||||
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
|
||||
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
|
||||
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
|
||||
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)); FreeCAD
|
||||
`.FCStd` and Rhino `.3dm` solids import the same way through
|
||||
[cadmpeg](https://github.com/cadmpeg/cadmpeg) in the desktop app and CLI.
|
||||
- **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the
|
||||
integrated product viewport and CPU preview PNGs. Existing simple colors remain supported.
|
||||
Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented.
|
||||
See [opaque surfaces](docs/render-opaque-pbr.md).
|
||||
- **Display meshes** from our own tessellation planner: each face becomes a plan of (u, v)
|
||||
points and triangles that shares its edge nodes with its neighbours, so the mesh is watertight.
|
||||
Facets follow the chord tolerance, the facet angle and a longest side, with cells at most 2:1
|
||||
along straight directions. Box-bounded and four-cornered faces mesh as structured grids.
|
||||
Trimmed faces are cut into four-sided patches: squares around holes, butterflies in round
|
||||
faces, motorcycle traces from concave corners (after Lyon et al. 2019). Each patch then meshes
|
||||
as a Coons grid. Intersection edges are evaluated by Newton on both surfaces. A compute shader
|
||||
evaluates the plans for the whole scene in one dispatch, with a CPU fallback when the GPU
|
||||
can't build it. Planning and evaluating the 78 reference bodies takes about 65 ms, 50–65×
|
||||
faster than the previous mesher.
|
||||
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
|
||||
axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
|
||||
control channel so agents and tests can drive it.
|
||||
Feature errors and Help can open an editable **Create Issue** draft with relevant context
|
||||
and an optional reviewed screenshot; opening GitHub does not submit the report.
|
||||
- **Headless CLI** `solidcraft-cli`: run command scripts, measure (volume, area, centre of mass,
|
||||
face/edge/vertex counts), export, render PNG snapshots and replay reference recipes.
|
||||
- **MCP server** `solidcraft-cli mcp`: AI agents model, measure and look at parts headless or
|
||||
in the running app (see below).
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solidcraft-cli -- commands # the command registry
|
||||
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
|
||||
cargo run --release -p solidcraft-cli -- render examples/render-studio.json --out product.png
|
||||
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
|
||||
cargo xtask ci # fmt, clippy, tests, icons, assets, layers, wasm
|
||||
```
|
||||
|
||||
UI artwork changes follow the [icon specification](assets/icons/ui/STYLE_GUIDE.md).
|
||||
The [included review tool](assets/icons/ui/README.md) discovers new icons and keeps
|
||||
unaccepted artwork Open; run `cargo xtask icons` to check the collection.
|
||||
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
On Windows, macOS and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis
|
||||
controller interface: a 3Dconnexion SpaceNavigator or SpaceMouse, or a homemade device that
|
||||
declares itself one. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
|
||||
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
|
||||
page in Preferences has an on/off switch, a speed control and, for each of the cap's six axes, an
|
||||
invert switch and a speed for the 3D mouse. The web app reads
|
||||
it too, through the browser; Firefox needs a 3DxWare setting first (see
|
||||
[Getting started › 3D mouse in the browser](docs/book/src/guide/getting-started.md#3d-mouse-in-the-browser)).
|
||||
|
||||
With 3DxWare on Windows, SolidCraft follows 3DxWare's profile for it when there is one, and
|
||||
3DxWare's object mode (tilt to roll, twist to spin) until there is. The mouse-wheel turns that
|
||||
3DxWare makes from the cap's motion are ignored, so the view doesn't zoom in steps as the cap tilts.
|
||||
|
||||
On Linux SolidCraft reads the 3D mouse through `spacenavd` when that daemon runs (its socket
|
||||
`/var/run/spnav.sock`), so what you set in `spnavcfg` (swapped or inverted axes, sensitivity, dead
|
||||
zones) applies as it does in other programs, and no udev rule is needed. With nothing set there,
|
||||
the motion is the same as without the daemon. Without `spacenavd`, SolidCraft reads `/dev/hidrawN`
|
||||
directly, so the logged-in user needs access to the device. A udev rule that adds the `uaccess`
|
||||
tag does that, for example
|
||||
`/etc/udev/rules.d/70-3d-mouse.rules` for USB devices with 3Dconnexion's vendor id, and for its
|
||||
Bluetooth ones (where `idVendor` isn't set, so the HID id is matched instead):
|
||||
|
||||
```
|
||||
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
|
||||
SUBSYSTEM=="hidraw", KERNELS=="0005:256F:*", TAG+="uaccess"
|
||||
```
|
||||
|
||||
Older SpaceNavigators use Logitech's vendor id `046d`, so add a rule with their product id (see
|
||||
`lsusb`) instead of matching every Logitech device. A homemade device needs a rule with its own
|
||||
vendor and product id. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then
|
||||
replug the device. The Flatpak sandbox does not expose hidraw devices, so the 3D mouse only works
|
||||
with native builds there for now.
|
||||
|
||||
SolidCraft finds the axes from the device's report descriptor, so it doesn't matter how a device
|
||||
lays them out: translation and rotation in separate reports (3Dconnexion's wired devices), all six
|
||||
in one (its wireless ones and most homemade devices), after the buttons, or narrower than 16 bits.
|
||||
Each axis is scaled from the range the descriptor declares.
|
||||
|
||||
On macOS SolidCraft talks to the 3Dconnexion driver (3DxWare) when it is installed, through the
|
||||
driver's client API (`crates/threedx-client`; the framework is loaded at run time, never bundled).
|
||||
The driver keeps the device to itself, and sends it only to a program it can identify: SolidCraft
|
||||
as an `.app` (the packaged app). Without the driver, or with its helper not running, SolidCraft reads the device through IOKit like on Windows.
|
||||
|
||||
SolidCraft does not follow 3DxWare's profile on macOS. Its default is 3DxWare's object mode.
|
||||
|
||||
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
|
||||
Agents can control the document open in a browser tab through `window.solidcraft.execute(...)`
|
||||
and `window.solidcraft.request(...)`; see the [browser command API](docs/browser-api.md).
|
||||
|
||||
## Scripts
|
||||
|
||||
A script is a JSON list of commands, the same ones the UI runs:
|
||||
|
||||
```json
|
||||
{"commands": [
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
|
||||
{"command": "solid.extrude", "params": {"distance": 20}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}}
|
||||
]}
|
||||
```
|
||||
|
||||
## AI agents (MCP)
|
||||
|
||||
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on
|
||||
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
|
||||
started with `solidcraft --control PORT`, so you can watch the agent model. Tools:
|
||||
`list_commands`, `execute`, `batch`, `inspect_design`, `measure`, `body_topology`,
|
||||
`set_parameter`, `screenshot` (returns the image), `export`, `crash_reports`, `undo`, `redo`,
|
||||
`new_design`, `open`, `save`. Tool calls reach files only in the open design's folder (when it lies inside your home
|
||||
folder, never home itself or a hidden folder), SolidCraft's recovery folder and folders given with
|
||||
`--allow-path DIR`. Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Details, client
|
||||
configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
<!-- ANCHOR: architecture -->
|
||||
| Layer | Crates | Role |
|
||||
|---|---|---|
|
||||
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
|
||||
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and the headless scene description |
|
||||
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
|
||||
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL/OBJ/glTF import features |
|
||||
| L4 | `engine` | the session and the command registry (everything is a command) |
|
||||
| L5 | `gpu`, `ui-egui`, `mcp` | compute-shader evaluation of display tessellation plans; the swappable desktop front end; the MCP server (headless or bridged to the app) |
|
||||
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
|
||||
<!-- ANCHOR_END: architecture -->
|
||||
|
||||
## Contributing
|
||||
|
||||
Coming from Autodesk Fusion? SolidCraft uses familiar parametric CAD workflows: constrained
|
||||
sketches, solid features, an editable timeline, parameters and assemblies. See the
|
||||
[roadmap](ROADMAP.md) for supported operations and current limitations.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md): how to build and test, the code map, the clean-room
|
||||
and licence rules, and where help is wanted most.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT OR Apache-2.0, at your option. Developed under the
|
||||
[clean-room contribution rules](CLAUDE.md); third-party code and assets retain their
|
||||
documented licences and attribution.
|
||||
|
||||
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
|
||||
conventions and user feedback. Reference tests use analytic values and open-source tools.
|
||||
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk. Autodesk and
|
||||
Fusion are trademarks of Autodesk, Inc.
|
||||
|
||||
@@ -14,6 +14,10 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||
|
||||
Optional GPU viewport ambient occlusion is available in View settings and Graphics preferences,
|
||||
with persistent settings, Performance/Quality graphics presets (Quality combines AO, Fine tessellation and adaptive 2× supersampling; manual combinations show Custom), and the
|
||||
`view.ambient_occlusion` and `view.graphics_preset` commands. See [display settings](docs/ambient-occlusion.md).
|
||||
|
||||
## Scope (owner decision, 2026-10-07)
|
||||
|
||||
The issue batch adds preservation of blind pockets in through cuts, associative projected
|
||||
@@ -48,14 +52,14 @@ 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, 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 | in progress | 30 |
|
||||
| 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 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 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator, extrude taper), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane, a face or sketch curves swept onto the surface, keeping a side or the picked regions; mutual trim of two surfaces, extended to meet and stitched, #767), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764: edge chains along one or several faces, closed rims paired seam to seam or from picked seams, a dialog), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown, and guide rails on solid and surface lofts (one or two, scaling or stretching the sections between; #201). Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), and every remaining #757–#772 item | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), 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); 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 in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -85,6 +89,9 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## Interactive modelling
|
||||
|
||||
- Modelled threads preview their cut before OK using the background worker on desktop.
|
||||
Browser users apply the cut with OK to avoid blocking on every edit.
|
||||
|
||||
- Thread resolves a suggested metric size when a cylindrical face is picked. Diameter and
|
||||
pitch selectors expose the existing metric designation parameter; custom designations stay
|
||||
editable, and Automatic size returns to the face's suggestion. Cosmetic/modelled and
|
||||
@@ -94,6 +101,8 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
drag continues, then catch up to the exact final value. Changes to the selected geometry,
|
||||
operation or feature still discard obsolete results. Distance arrows and their value boxes
|
||||
follow the current frame's input.
|
||||
- Large fine modelled threads use pitch-based helix resolution and tighter faceted
|
||||
reconstruction fallbacks, preserving thin crests and closed shells on large shafts.
|
||||
|
||||
## Physical material data
|
||||
|
||||
@@ -220,6 +229,9 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
Type a value and press Enter to commit one undo step; rejected values remain editable and
|
||||
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
|
||||
and navigation controls, including enlarged displays and nested pending placements.
|
||||
- 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 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.
|
||||
@@ -338,13 +350,16 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
- Associative shell-face sketches require a unique certified planar source. Nested shell
|
||||
attachments and ambiguous coincident sources remain unsupported.
|
||||
- G2 fillets: exact quintic blends of straight edges between planar faces, with one perpendicular
|
||||
planar end face at each end. The UI and `solid.fillet` expose `curvature` (alias `g2`); the size
|
||||
is an equivalent circular setback. Curved faces/edges, sloped ends and corner junctions remain unsupported.
|
||||
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
|
||||
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
|
||||
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
|
||||
multi-face ends and corner junctions remain unsupported.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
|
||||
rounds preserve the ring's radii and the enclosed planar face (#1557);
|
||||
mitres between separate rounds of the same radius keep their endpoint tangent, so a later
|
||||
fillet can select an upright and its adjoining bottom mitre as one chain (#2422);
|
||||
straight edges (convex or concave) between planar faces with perpendicular
|
||||
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
|
||||
@@ -453,10 +468,13 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
and author occurrence paths retain the chosen placements; missing/split faces refuse rather
|
||||
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
|
||||
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
|
||||
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
|
||||
- Shell and offset faces work on bodies of planes, cylinders, cones, spheres and tori (convex or not:
|
||||
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
|
||||
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
|
||||
sections ruled.
|
||||
sections ruled. Circular-meridian barrels retain curved inner walls through either axial
|
||||
opening (#2421). Solid lofts preserve holes through sections with matching hole counts
|
||||
(#2435), including inner walls and caps with openings; a hole cannot appear or disappear
|
||||
between sections, and sections with holes cannot collapse to a point.
|
||||
|
||||
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
|
||||
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Third-party software in SolidCraft
|
||||
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 552 crates below.
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 560 crates below.
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
|
||||
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
|
||||
|
||||
@@ -75,6 +75,7 @@ cbc 0.1.2 (MIT OR Apache-2.0)
|
||||
cfg-if 1.0.5 (MIT OR Apache-2.0)
|
||||
cgl 0.3.2 (MIT / Apache-2.0)
|
||||
cgmath 0.18.0 (Apache-2.0)
|
||||
chardetng 0.1.17 (Apache-2.0 OR MIT)
|
||||
chrono 0.4.45 (MIT OR Apache-2.0)
|
||||
cipher 0.4.4 (MIT OR Apache-2.0)
|
||||
clipboard-win 5.4.1 (BSL-1.0)
|
||||
@@ -113,6 +114,7 @@ dlib 0.5.3 (MIT)
|
||||
document-features 0.2.12 (MIT OR Apache-2.0)
|
||||
downcast-rs 1.2.1 (MIT/Apache-2.0)
|
||||
dpi 0.1.2 (Apache-2.0 AND MIT)
|
||||
dyn-clone 1.0.20 (MIT OR Apache-2.0)
|
||||
ecolor 0.36.2 (MIT OR Apache-2.0)
|
||||
eframe 0.36.2 (MIT OR Apache-2.0)
|
||||
egui 0.36.2 (MIT OR Apache-2.0)
|
||||
@@ -331,6 +333,8 @@ read-fonts 0.41.0 (MIT OR Apache-2.0)
|
||||
redox_syscall 0.4.1 (MIT)
|
||||
redox_syscall 0.5.18 (MIT)
|
||||
redox_syscall 0.9.4 (MIT)
|
||||
ref-cast 1.0.27 (MIT OR Apache-2.0)
|
||||
ref-cast-impl 1.0.27 (MIT OR Apache-2.0)
|
||||
renderdoc-sys 1.1.0 (MIT OR Apache-2.0)
|
||||
rfd 0.17.2 (MIT)
|
||||
ring 0.17.14 (Apache-2.0 AND ISC)
|
||||
@@ -349,6 +353,8 @@ rustls-platform-verifier-android 0.2.0 (MIT OR Apache-2.0)
|
||||
rustls-webpki 0.103.15 (ISC)
|
||||
rustversion 1.0.23 (MIT OR Apache-2.0)
|
||||
schannel 0.1.29 (MIT)
|
||||
schemars 1.2.3 (MIT)
|
||||
schemars_derive 1.2.3 (MIT)
|
||||
scoped-tls 1.0.1 (MIT/Apache-2.0)
|
||||
scopeguard 1.2.0 (MIT OR Apache-2.0)
|
||||
secret-service 4.0.0 (MIT OR Apache-2.0)
|
||||
@@ -361,7 +367,9 @@ serde 1.0.229 (MIT OR Apache-2.0)
|
||||
serde-value 0.7.0 (MIT)
|
||||
serde_core 1.0.229 (MIT OR Apache-2.0)
|
||||
serde_derive 1.0.229 (MIT OR Apache-2.0)
|
||||
serde_derive_internals 0.30.0 (MIT OR Apache-2.0)
|
||||
serde_json 1.0.151 (MIT OR Apache-2.0)
|
||||
serde_path_to_error 0.1.20 (MIT OR Apache-2.0)
|
||||
serde_repr 0.1.21 (MIT OR Apache-2.0)
|
||||
serde_spanned 1.1.2 (MIT OR Apache-2.0)
|
||||
sha1 0.10.7 (MIT OR Apache-2.0)
|
||||
@@ -578,19 +586,21 @@ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
adler2 2.0.1, allocator-api2 0.2.21, async-channel 2.5.0, async-executor 1.14.0, async-fs 2.2.0,
|
||||
async-io 2.6.0, async-lock 3.4.2, async-process 2.5.0, async-recursion 1.2.0, async-signal 0.2.14,
|
||||
async-task 4.7.1, async-trait 0.1.92, atomic-waker 1.1.2, blocking 1.7.0, concurrent-queue 2.5.0,
|
||||
displaydoc 0.2.7, endi 1.1.1, enumn 0.1.14, event-listener 5.4.2, event-listener-strategy 0.5.4,
|
||||
fastrand 2.5.0, futures-lite 2.6.1, glow 0.17.0, hermit-abi 0.5.3, itoa 1.0.18, khronos-egl 6.0.0,
|
||||
lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, num_enum 0.7.6, num_enum_derive
|
||||
0.7.6, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1, pin-project 1.1.13,
|
||||
pin-project-internal 1.1.13, pin-project-lite 0.2.17, piper 0.2.5, polling 3.11.0, portable-atomic
|
||||
1.15.0, portable-atomic-util 0.2.8, proc-macro-crate 3.5.0, proc-macro2 1.0.107, quote 1.0.47,
|
||||
rustc-hash 1.1.0, rustc-hash 2.1.3, rustix 0.38.44, rustix 1.1.5, rustversion 1.0.23, semver 1.0.28,
|
||||
serde 1.0.229, serde_core 1.0.229, serde_derive 1.0.229, serde_json 1.0.151, serde_repr 0.1.21,
|
||||
simd_cesu8 1.2.0, smol_str 0.2.2, syn 1.0.109, syn 2.0.119, syn 3.0.6, thiserror 1.0.69, thiserror
|
||||
2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.12.3, typeid 1.0.3, unicode-ident
|
||||
1.0.26, utf8-zero 0.8.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wit-bindgen
|
||||
0.57.1, x11-dl 2.21.0, xdg-home 1.3.0, zbus-lockstep 0.5.2, zbus-lockstep-macros 0.5.2, zbus_names
|
||||
3.0.0, zmij 1.0.23, zvariant_utils 2.1.0, zvariant_utils 4.2.0
|
||||
displaydoc 0.2.7, dyn-clone 1.0.20, endi 1.1.1, enumn 0.1.14, event-listener 5.4.2,
|
||||
event-listener-strategy 0.5.4, fastrand 2.5.0, futures-lite 2.6.1, glow 0.17.0, hermit-abi 0.5.3,
|
||||
itoa 1.0.18, khronos-egl 6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15,
|
||||
num_enum 0.7.6, num_enum_derive 0.7.6, once_cell 1.21.4, ordered-stream 0.2.0, parking 2.2.1,
|
||||
pin-project 1.1.13, pin-project-internal 1.1.13, pin-project-lite 0.2.17, piper 0.2.5, polling
|
||||
3.11.0, portable-atomic 1.15.0, portable-atomic-util 0.2.8, proc-macro-crate 3.5.0, proc-macro2
|
||||
1.0.107, quote 1.0.47, ref-cast 1.0.27, ref-cast-impl 1.0.27, rustc-hash 1.1.0, rustc-hash 2.1.3,
|
||||
rustix 0.38.44, rustix 1.1.5, rustversion 1.0.23, semver 1.0.28, serde 1.0.229, serde_core 1.0.229,
|
||||
serde_derive 1.0.229, serde_derive_internals 0.30.0, serde_json 1.0.151, serde_path_to_error 0.1.20,
|
||||
serde_repr 0.1.21, simd_cesu8 1.2.0, smol_str 0.2.2, syn 1.0.109, syn 2.0.119, syn 3.0.6, thiserror
|
||||
1.0.69, thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.12.3, typeid
|
||||
1.0.3, unicode-ident 1.0.26, utf8-zero 0.8.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2
|
||||
1.0.4+wasi-0.2.12, wit-bindgen 0.57.1, x11-dl 2.21.0, xdg-home 1.3.0, zbus-lockstep 0.5.2,
|
||||
zbus-lockstep-macros 0.5.2, zbus_names 3.0.0, zmij 1.0.23, zvariant_utils 2.1.0, zvariant_utils
|
||||
4.2.0
|
||||
================================================================================
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
@@ -1323,12 +1333,13 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
================================================================================
|
||||
allocator-api2 0.2.21, async-trait 0.1.92, enumn 0.1.14, glifo 0.2.0, itoa 1.0.18, libc 0.2.190,
|
||||
litrs 1.0.0, num_enum 0.7.6, num_enum_derive 0.7.6, proc-macro2 1.0.107, quote 1.0.47, rustc-hash
|
||||
2.1.3, rustversion 1.0.23, semver 1.0.28, serde 1.0.229, serde_core 1.0.229, serde_derive 1.0.229,
|
||||
serde_json 1.0.151, serde_repr 0.1.21, syn 2.0.119, syn 3.0.6, thiserror 1.0.69, thiserror 2.0.21,
|
||||
thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.12.3, typeid 1.0.3, unicode-ident 1.0.26,
|
||||
utf8-zero 0.8.1
|
||||
allocator-api2 0.2.21, async-trait 0.1.92, dyn-clone 1.0.20, enumn 0.1.14, glifo 0.2.0, itoa 1.0.18,
|
||||
libc 0.2.190, litrs 1.0.0, num_enum 0.7.6, num_enum_derive 0.7.6, proc-macro2 1.0.107, quote 1.0.47,
|
||||
ref-cast 1.0.27, ref-cast-impl 1.0.27, rustc-hash 2.1.3, rustversion 1.0.23, semver 1.0.28, serde
|
||||
1.0.229, serde_core 1.0.229, serde_derive 1.0.229, serde_derive_internals 0.30.0, serde_json
|
||||
1.0.151, serde_path_to_error 0.1.20, serde_repr 0.1.21, syn 2.0.119, syn 3.0.6, thiserror 1.0.69,
|
||||
thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.12.3, typeid 1.0.3,
|
||||
unicode-ident 1.0.26, utf8-zero 0.8.1
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
@@ -1821,8 +1832,8 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
approx 0.4.0, as-raw-xcb-connection 1.0.1, atspi 0.29.0, atspi-common 0.13.0, atspi-proxies 0.13.0,
|
||||
cgmath 0.18.0, codespan-reporting 0.13.1, encoding_rs 0.8.42, kurbo 0.13.1, multiversion_no_op
|
||||
1.0.0, nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0,
|
||||
cgmath 0.18.0, chardetng 0.1.17, codespan-reporting 0.13.1, encoding_rs 0.8.42, kurbo 0.13.1,
|
||||
multiversion_no_op 1.0.0, nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0,
|
||||
rustls-platform-verifier 0.7.1, self_cell 1.3.0, siphasher 1.0.4, spade 2.15.1, static_assertions
|
||||
1.1.0, ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol 0.13.2, zeroize
|
||||
1.9.1, zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15
|
||||
@@ -3324,6 +3335,50 @@ http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
|
||||
option. All files in the project carrying such notice may not be
|
||||
copied, modified, or distributed except according to those terms.
|
||||
|
||||
================================================================================
|
||||
chardetng 0.1.17
|
||||
================================================================================
|
||||
|
||||
chardetng is copyright Mozilla Foundation.
|
||||
|
||||
Licensed under the Apache License, Version 2.0
|
||||
<LICENSE-APACHE or
|
||||
https://www.apache.org/licenses/LICENSE-2.0> or the MIT
|
||||
license <LICENSE-MIT or https://opensource.org/licenses/MIT>,
|
||||
at your option. All files in the project carrying such
|
||||
notice may not be copied, modified, or distributed except
|
||||
according to those terms.
|
||||
|
||||
================================================================================
|
||||
chardetng 0.1.17, encoding_rs 0.8.42, multiversion_no_op 1.0.0, utf8_iter 1.0.4
|
||||
================================================================================
|
||||
|
||||
Copyright Mozilla Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
chrono 0.4.45
|
||||
================================================================================
|
||||
@@ -5010,36 +5065,6 @@ source code, are licensed under the BSD 3-Clause License
|
||||
Test code within encoding_rs is dedicated to the Public Domain when so
|
||||
designated (see the individual files for PD/CC0-dedicated sections).
|
||||
|
||||
================================================================================
|
||||
encoding_rs 0.8.42, multiversion_no_op 1.0.0, utf8_iter 1.0.4
|
||||
================================================================================
|
||||
|
||||
Copyright Mozilla Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
enumflags2 0.7.12
|
||||
================================================================================
|
||||
@@ -11768,6 +11793,32 @@ The above copyright notice and this permission notice shall be included in all c
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
schemars 1.2.3, schemars_derive 1.2.3
|
||||
================================================================================
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Graham Esau
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
scopeguard 1.2.0
|
||||
================================================================================
|
||||
|
||||
@@ -4,14 +4,16 @@
|
||||
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
|
||||
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines
|
||||
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
|
||||
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…|NAME | --camera JSON] [--no-grid] [--no-axes]
|
||||
//! [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
|
||||
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solidcraft-cli commands the command registry as JSON
|
||||
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
|
||||
//! solidcraft-cli mcp [--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 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)
|
||||
@@ -23,6 +25,7 @@ mod bench_agent;
|
||||
mod crashes;
|
||||
mod fuzz;
|
||||
mod oracle;
|
||||
mod preview;
|
||||
mod recipe;
|
||||
mod render;
|
||||
mod spatial;
|
||||
@@ -47,14 +50,21 @@ const USAGE: &str = "usage:
|
||||
as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)
|
||||
solidcraft-cli script <design.solidcraft|script.json> [--out script.json]
|
||||
(the design's history as a command script that eval and run replay)
|
||||
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]]
|
||||
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]]
|
||||
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
|
||||
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]
|
||||
[--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
|
||||
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
|
||||
(as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;
|
||||
--view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;
|
||||
--camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);
|
||||
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture)
|
||||
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;
|
||||
--body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside
|
||||
it shows) and color #rrggbb, either or both, once per body;
|
||||
--tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body
|
||||
is visible inside the material it meets; a later --body overrides it;
|
||||
--section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world
|
||||
coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON)
|
||||
solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]
|
||||
solidcraft-cli exec <command> [json-params]
|
||||
solidcraft-cli commands
|
||||
@@ -62,6 +72,7 @@ const USAGE: &str = "usage:
|
||||
solidcraft-cli oracle <case-dir>... [--json]
|
||||
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
||||
solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
|
||||
[--param-check off|warn|strict]
|
||||
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
|
||||
solidcraft-cli licences
|
||||
solidcraft-cli gpu [--policy auto|hardware|software|cpu]
|
||||
@@ -145,7 +156,14 @@ pub fn read_json(path: &str) -> Result<Value, String> {
|
||||
|
||||
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
|
||||
pub fn load(path: &str) -> Result<Session, String> {
|
||||
load_checked(path, solidcraft_engine::schema::ParamCheck::Off)
|
||||
}
|
||||
|
||||
/// `load`, with scripts and recipes run at the parameter-check level `check`, which the
|
||||
/// session keeps.
|
||||
fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
|
||||
let mut s = Session::default();
|
||||
s.param_check = check;
|
||||
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) {
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
return Ok(s);
|
||||
@@ -155,16 +173,22 @@ pub fn load(path: &str) -> Result<Session, String> {
|
||||
let doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut session = Session::new(doc);
|
||||
session.path = Some(path.to_owned());
|
||||
session.param_check = check;
|
||||
return Ok(session);
|
||||
}
|
||||
let v: Value = serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
if v.get("format").and_then(Value::as_str).is_some_and(|f| solidcraft_engine::doc::format::version_of(f).is_some()) {
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
} else if v.get("features").is_some() && v.get("commands").is_none() {
|
||||
let script = recipe::to_script(&v)?;
|
||||
s.run_script(&script).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
let script = if v.get("features").is_some() && v.get("commands").is_none() { recipe::to_script(&v)? } else { v };
|
||||
// Warn level: each step's `param_warnings` as it completes, even when a later step fails
|
||||
// (stderr, so stdout stays the caller's).
|
||||
let mut warn = |step: usize, r: &Value| {
|
||||
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
|
||||
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
|
||||
}
|
||||
};
|
||||
s.run_script_each(&script, &mut warn).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
@@ -405,7 +429,12 @@ fn install_renderer() {
|
||||
|
||||
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view", "--camera"], &["--no-grid", "--no-axes"])?;
|
||||
known_flags(
|
||||
"snapshot",
|
||||
args,
|
||||
&["--in", "--out", "--width", "--height", "--view", "--camera", "--body", "--tool", "--section"],
|
||||
&["--no-grid", "--no-axes"],
|
||||
)?;
|
||||
if positional(args).is_none() && flag(args, "--in").is_none() {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
@@ -416,6 +445,9 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
};
|
||||
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
|
||||
let mut s = open(flag(args, "--in"), positional(args))?;
|
||||
// How the bodies are drawn and where the model is cut, through the commands the app's
|
||||
// Appearance dialog and Section Analysis run (`preview`).
|
||||
preview::apply(&mut s, &flags(args, "--body"), &flags(args, "--tool"), flag(args, "--section"))?;
|
||||
// The camera choices of the MCP `screenshot`, through the same command (`view.apply`): a
|
||||
// standard view, a view saved in the design, or an explicit camera. A script that applies a
|
||||
// view itself sets the camera too; without any, the iso view framing the model.
|
||||
@@ -519,6 +551,17 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
|
||||
}
|
||||
}
|
||||
// How a headless session treats calls that break a command's parameter contract (docs/mcp.md).
|
||||
let param_check = match flag(args, "--param-check") {
|
||||
None if args.iter().any(|a| a == "--param-check") => return Err("mcp: --param-check wants off, warn or strict".into()),
|
||||
None => solidcraft_engine::schema::ParamCheck::Off,
|
||||
Some(v) => {
|
||||
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("mcp: --param-check wants off, warn or strict, not `{v}`"))?
|
||||
}
|
||||
};
|
||||
if param_check != solidcraft_engine::schema::ParamCheck::Off && flag(args, "--connect").is_some() {
|
||||
eprintln!("solidcraft mcp: --param-check applies to headless sessions; calls to the app are not checked yet");
|
||||
}
|
||||
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
|
||||
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
|
||||
(Some(addr), None) => {
|
||||
@@ -535,8 +578,12 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
Box::new(remote)
|
||||
}
|
||||
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load(path)?)),
|
||||
(None, None) => Box::new(solidcraft_mcp::Headless::default()),
|
||||
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load_checked(path, param_check)?)),
|
||||
(None, None) => {
|
||||
let mut headless = solidcraft_mcp::Headless::default();
|
||||
headless.session.param_check = param_check;
|
||||
Box::new(headless)
|
||||
}
|
||||
};
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
@@ -607,37 +654,79 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let t = std::time::Instant::now();
|
||||
// The file as read, held until the timeline has evaluated it (as the import command does).
|
||||
let mut read = None;
|
||||
let doc = if step {
|
||||
let (doc, saved) = if step {
|
||||
let imported = solidcraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
|
||||
let mut doc = solidcraft_engine::doc::Document::new(&imported.name);
|
||||
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
|
||||
read = Some(imported.import);
|
||||
doc
|
||||
(doc, None)
|
||||
} else {
|
||||
solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
|
||||
// As Open reads it: with the model the file keeps.
|
||||
solidcraft_engine::io::read_design_file(path, None::<fn(solidcraft_engine::io::DesignPreview)>).map_err(|e| e.to_string())?
|
||||
};
|
||||
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
|
||||
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len(), "saved_model": saved.is_some()}));
|
||||
if args.iter().any(|a| a == "--parse-only") {
|
||||
return Ok(());
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let s = Session::new(doc);
|
||||
let s = Session::opened(doc, saved.as_ref());
|
||||
drop(read);
|
||||
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0}));
|
||||
// Taken from the file: nothing evaluated (SOLIDCRAFT_NO_MODEL_CACHE evaluates anyway).
|
||||
let adopted = saved.is_some() && s.model.last_recomputed == 0;
|
||||
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0, "adopted": adopted}));
|
||||
for r in &s.model.results {
|
||||
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let st = s.world_state();
|
||||
let place_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
// As the desktop app prepares a design it opened.
|
||||
st.warm_meshes(|| true);
|
||||
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
|
||||
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
|
||||
println!(
|
||||
"{}",
|
||||
json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "place_ms": place_ms, "bodies": st.bodies.len(), "triangles": triangles})
|
||||
);
|
||||
let flag = |name: &str| args.iter().position(|a| a == name);
|
||||
// `--save PATH`: save the opened design without its model cache (to PATH.plain.solidcraft) and
|
||||
// as the app does, with it, then read the cache back.
|
||||
let s = match flag("--save").and_then(|i| args.get(i + 1)) {
|
||||
Some(out) => {
|
||||
let mut s = s;
|
||||
// Without the cache first, for comparison.
|
||||
let plain = format!("{out}.plain.solidcraft");
|
||||
let t = std::time::Instant::now();
|
||||
s.execute("file.save_as", &json!({"path": plain, "cache": false})).map_err(|e| e.to_string())?;
|
||||
let plain_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let plain_bytes = std::fs::metadata(&plain).map(|m| m.len()).unwrap_or(0);
|
||||
let t = std::time::Instant::now();
|
||||
s.execute("file.save_as", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
let save_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let bytes = std::fs::read(out).map_err(|e| format!("{out}: {e}"))?;
|
||||
let t = std::time::Instant::now();
|
||||
let cached = solidcraft_engine::io::read_design_geometry(&bytes).map(|m| m.bodies.len());
|
||||
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
// Why bodies could not be kept exactly, when the cache was left out.
|
||||
let mut refused = std::collections::BTreeMap::<String, usize>::new();
|
||||
if cached.is_none() {
|
||||
for b in &s.model.state().bodies {
|
||||
if let Err(e) = solidcraft_engine::kernel::encode_native_body(&b.body) {
|
||||
*refused.entry(e.to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
json!({"step": "save", "ms": save_ms, "bytes": bytes.len(), "plain_ms": plain_ms, "plain_bytes": plain_bytes, "cached_bodies": cached, "cache_read_ms": read_ms, "refused": refused})
|
||||
);
|
||||
s
|
||||
}
|
||||
None => s,
|
||||
};
|
||||
// `--display LEVEL`: the display plans the viewport draws at that display quality, made as
|
||||
// the GPU viewport makes them; `--edit NAME EXPR` then changes a parameter and times the
|
||||
// evaluation and the plans of the bodies it changed.
|
||||
let flag = |name: &str| args.iter().position(|a| a == name);
|
||||
let Some(level) = flag("--display").and_then(|i| args.get(i + 1)) else { return Ok(()) };
|
||||
let q = solidcraft_engine::doc::display_quality::DisplayQuality::parse(level).ok_or("--display: coarse, normal, fine or ultra")?;
|
||||
let plans = |st: &solidcraft_engine::doc::ModelState| {
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! What a `snapshot` draws see-through, what it draws in a colour of its own, and where it cuts
|
||||
//! the model: the `--body`, `--tool` and `--section` options.
|
||||
//!
|
||||
//! None of this is a renderer of its own. Each option turns into the command the app itself runs
|
||||
//! — `appearance.assign` for the Appearance dialog, `inspect.section` for Section Analysis — so
|
||||
//! a picture of a tool body inside a translucent target is the picture the window would show
|
||||
//! with the same appearances assigned, and a script can do the same thing without the options.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
|
||||
/// How see-through the bodies around a `--tool` are drawn: enough to read the tool through them,
|
||||
/// solid enough to keep the shape of the material it meets.
|
||||
pub const TARGET_OPACITY: f64 = 0.3;
|
||||
|
||||
/// A `--tool` body's colour: the hue the viewport's feature preview uses for removed material,
|
||||
/// so a cut's tool looks in a picture the way the preview looks in the window.
|
||||
pub const TOOL_COLOR: &str = "#e8542c";
|
||||
|
||||
/// The colour of a body with no appearance of its own ([`solidcraft_engine::view::colors::BODY`]),
|
||||
/// as `appearance.assign` takes it. Giving a body an opacity means giving it a look, and a look
|
||||
/// carries a colour: this keeps the one it is already drawn in.
|
||||
fn default_body_hex() -> String {
|
||||
let c = solidcraft_engine::view::colors::BODY;
|
||||
format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2)
|
||||
}
|
||||
|
||||
/// How one body is drawn: `--body NAME:opacity=0.3,color=#cc3333`. Either key alone is enough;
|
||||
/// the other keeps what the design already says.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct BodyLook {
|
||||
pub name: String,
|
||||
pub opacity: Option<f64>,
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
/// `NAME:key=value[,key=value]`, or `NAME` on its own (which says nothing and is an error:
|
||||
/// a body is named here to change how it is drawn).
|
||||
pub fn parse_body(spec: &str) -> Result<BodyLook, String> {
|
||||
let (name, rest) = spec
|
||||
.split_once(':')
|
||||
.ok_or_else(|| format!("--body `{spec}` must be NAME:key=value (`Target:opacity=0.3`, `Tool:color=#cc3333`, keys separated by commas)"))?;
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(format!("--body `{spec}` names no body"));
|
||||
}
|
||||
let mut look = BodyLook { name: name.to_owned(), opacity: None, color: None };
|
||||
for part in rest.split(',') {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (key, value) = part.split_once('=').ok_or_else(|| format!("--body {name}: `{part}` must be key=value (opacity, color)"))?;
|
||||
let value = value.trim();
|
||||
match key.trim().to_ascii_lowercase().as_str() {
|
||||
"opacity" => {
|
||||
let o: f64 = value.parse().map_err(|_| format!("--body {name}: opacity must be a number 0 to 1, not `{value}`"))?;
|
||||
if !o.is_finite() || !(0.0..=1.0).contains(&o) {
|
||||
return Err(format!("--body {name}: opacity must be 0 to 1, not `{value}`"));
|
||||
}
|
||||
look.opacity = Some(o);
|
||||
}
|
||||
"color" => {
|
||||
let digits = value.strip_prefix('#').filter(|h| h.len() == 6 && h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
let digits = digits.ok_or_else(|| format!("--body {name}: color must be #rrggbb, not `{value}`"))?;
|
||||
look.color = Some(format!("#{}", digits.to_ascii_lowercase()));
|
||||
}
|
||||
other => return Err(format!("--body {name}: unknown key `{other}` (opacity, color)")),
|
||||
}
|
||||
}
|
||||
if look.opacity.is_none() && look.color.is_none() {
|
||||
return Err(format!("--body `{spec}` asks for nothing: give opacity, color, or both"));
|
||||
}
|
||||
Ok(look)
|
||||
}
|
||||
|
||||
/// The `inspect.section` parameters a `--section` spec asks for: `z=27` (the plane through that
|
||||
/// world coordinate, its normal along +Z), `XY` (a named plane through the origin), either with
|
||||
/// `,flip` to keep the other side, or the command's own JSON object.
|
||||
pub fn parse_section(spec: &str) -> Result<Value, String> {
|
||||
let spec = spec.trim();
|
||||
if spec.starts_with('{') {
|
||||
return serde_json::from_str(spec).map_err(|e| format!("--section: not JSON: {e}"));
|
||||
}
|
||||
let mut parts = spec.split(',').map(str::trim);
|
||||
let plane = parts.next().unwrap_or_default();
|
||||
let mut flip = false;
|
||||
for p in parts {
|
||||
match p.to_ascii_lowercase().as_str() {
|
||||
"" => {}
|
||||
"flip" => flip = true,
|
||||
other => return Err(format!("--section: unknown `{other}` (only `flip` follows the plane)")),
|
||||
}
|
||||
}
|
||||
// A named plane through the world origin, as `inspect.section` takes it.
|
||||
let mut params = match plane.to_ascii_uppercase().as_str() {
|
||||
p @ ("XY" | "XZ" | "YZ") => json!({"plane": p}),
|
||||
_ => {
|
||||
// AXIS=VALUE: the plane whose normal is that axis, cutting at that world coordinate.
|
||||
let (axis, at) = plane.split_once('=').ok_or_else(|| {
|
||||
format!("--section `{spec}` must be z=27 (a world coordinate on an axis), XY, XZ, YZ, or the JSON inspect.section takes")
|
||||
})?;
|
||||
let at: f64 = at.trim().parse().map_err(|_| format!("--section {axis}: `{}` is not a number", at.trim()))?;
|
||||
if !at.is_finite() {
|
||||
return Err(format!("--section {axis}: `{at}` is not a finite coordinate"));
|
||||
}
|
||||
// The plane normal to each axis: Z is XY, Y is XZ, X is YZ.
|
||||
let named = match axis.trim().to_ascii_lowercase().as_str() {
|
||||
"x" => "YZ",
|
||||
"y" => "XZ",
|
||||
"z" => "XY",
|
||||
other => return Err(format!("--section: unknown axis `{other}` (x, y or z)")),
|
||||
};
|
||||
json!({"plane": named, "at": at})
|
||||
}
|
||||
};
|
||||
if flip {
|
||||
params["flip"] = json!(true);
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
/// The design's body names, for an error that says which ones there are.
|
||||
fn body_names(s: &Session) -> Vec<String> {
|
||||
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// The colour and opacity a body is drawn with now, as `appearance.assign` takes them: its own
|
||||
/// appearance, else its component's or material's, else the default body colour.
|
||||
fn current_look(s: &mut Session, name: &str) -> Result<(String, f64), String> {
|
||||
let reply = s.execute("appearance.list", &json!({"body": name})).map_err(|e| e.to_string())?;
|
||||
let body = reply.get("body");
|
||||
let look = body.and_then(|b| b.get("appearance")).filter(|a| !a.is_null());
|
||||
let hex = look
|
||||
.and_then(|a| a.get("color"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
// No appearance, but an imported colour: `body.color` is that colour, 0..1 per channel.
|
||||
.or_else(|| {
|
||||
let c = body?.get("color")?.as_array()?;
|
||||
let ch = |i: usize| -> Option<u8> { Some((c.get(i)?.as_f64()?.clamp(0.0, 1.0) * 255.0).round() as u8) };
|
||||
Some(format!("#{:02x}{:02x}{:02x}", ch(0)?, ch(1)?, ch(2)?))
|
||||
})
|
||||
.unwrap_or_else(default_body_hex);
|
||||
let opacity = look.and_then(|a| a.get("opacity")).and_then(Value::as_f64).filter(|o| o.is_finite()).unwrap_or(1.0);
|
||||
Ok((hex, opacity))
|
||||
}
|
||||
|
||||
/// Draw `name` with `look`, keeping whichever of colour and opacity it does not give.
|
||||
fn assign(s: &mut Session, look: &BodyLook) -> Result<(), String> {
|
||||
let name = &look.name;
|
||||
if !body_names(s).iter().any(|b| b == name) {
|
||||
return Err(format!("no body `{name}` in the design (it has {})", body_list(s)));
|
||||
}
|
||||
let (hex, opacity) = current_look(s, name)?;
|
||||
let params = json!({
|
||||
"bodies": [name],
|
||||
"color": look.color.clone().unwrap_or(hex),
|
||||
"opacity": look.opacity.unwrap_or(opacity),
|
||||
});
|
||||
s.execute("appearance.assign", ¶ms).map(|_| ()).map_err(|e| format!("drawing `{name}`: {e}"))
|
||||
}
|
||||
|
||||
/// The design's bodies, named, for an error message.
|
||||
fn body_list(s: &Session) -> String {
|
||||
let names = body_names(s);
|
||||
if names.is_empty() { "no bodies".to_owned() } else { names.join(", ") }
|
||||
}
|
||||
|
||||
/// Apply the options to the session, in the order a later one overrides an earlier: the `--tool`
|
||||
/// preview first (it speaks for every body), then each `--body`, then the section.
|
||||
pub fn apply(s: &mut Session, bodies: &[&str], tools: &[&str], section: Option<&str>) -> Result<(), String> {
|
||||
// Everything is read before anything is drawn, so a misspelt option is an error rather than
|
||||
// half a picture.
|
||||
let looks: Vec<BodyLook> = bodies.iter().map(|spec| parse_body(spec)).collect::<Result<_, _>>()?;
|
||||
let section = section.map(parse_section).transpose()?;
|
||||
if !tools.is_empty() {
|
||||
let names = body_names(s);
|
||||
for t in tools {
|
||||
if !names.iter().any(|b| b == t) {
|
||||
return Err(format!("--tool: no body `{t}` in the design (it has {})", body_list(s)));
|
||||
}
|
||||
}
|
||||
// Everything but the tool see-through, in the colour it already has, so the tool reads
|
||||
// against the material it meets. Bodies that share a colour are assigned together: a
|
||||
// design of many bodies then costs a command or two rather than one for each.
|
||||
let mut around: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
|
||||
for name in names.iter().filter(|n| !tools.contains(&n.as_str())) {
|
||||
around.entry(current_look(s, name)?.0).or_default().push(name.clone());
|
||||
}
|
||||
for (hex, sharing_it) in around {
|
||||
let params = json!({"bodies": sharing_it, "color": hex, "opacity": TARGET_OPACITY});
|
||||
s.execute("appearance.assign", ¶ms).map_err(|e| format!("--tool: {e}"))?;
|
||||
}
|
||||
// Then the tool itself, solid in the tool colour.
|
||||
let params = json!({"bodies": tools, "color": TOOL_COLOR, "opacity": 1.0});
|
||||
s.execute("appearance.assign", ¶ms).map_err(|e| format!("--tool: {e}"))?;
|
||||
}
|
||||
for look in &looks {
|
||||
assign(s, look)?;
|
||||
}
|
||||
if let Some(params) = section {
|
||||
s.execute("inspect.section", ¶ms).map_err(|e| format!("--section: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `--body` names a body and says how it is drawn. Either key alone is enough.
|
||||
#[test]
|
||||
fn a_body_spec_is_a_name_and_the_keys_appearance_assign_takes() {
|
||||
let look = |s: &str| parse_body(s).unwrap();
|
||||
assert_eq!(look("Target:opacity=0.3"), BodyLook { name: "Target".into(), opacity: Some(0.3), color: None });
|
||||
assert_eq!(look("Tool:color=#CC3333"), BodyLook { name: "Tool".into(), opacity: None, color: Some("#cc3333".into()) });
|
||||
assert_eq!(look("Tool: opacity = 1 , color = #00ff00 "), BodyLook { name: "Tool".into(), opacity: Some(1.0), color: Some("#00ff00".into()) });
|
||||
// A body name with a space in it, as the browser shows them.
|
||||
assert_eq!(look("Bracket arm:opacity=0").name, "Bracket arm");
|
||||
assert_eq!(look("A:opacity=0").opacity, Some(0.0));
|
||||
}
|
||||
|
||||
/// Every way of getting `--body` wrong is an error that says what it takes, never a panic
|
||||
/// and never a silently ignored option.
|
||||
#[test]
|
||||
fn a_bad_body_spec_says_what_it_takes() {
|
||||
for (spec, expect) in [
|
||||
("Target", "NAME:key=value"),
|
||||
(":opacity=0.3", "names no body"),
|
||||
("Target:", "asks for nothing"),
|
||||
("Target:opacity", "key=value"),
|
||||
("Target:opacity=high", "0 to 1"),
|
||||
("Target:opacity=1.5", "0 to 1"),
|
||||
("Target:opacity=-0.2", "0 to 1"),
|
||||
("Target:opacity=NaN", "0 to 1"),
|
||||
("Target:opacity=inf", "0 to 1"),
|
||||
("Target:color=red", "#rrggbb"),
|
||||
("Target:color=#ccc", "#rrggbb"),
|
||||
("Target:color=#gggggg", "#rrggbb"),
|
||||
("Target:alpha=0.3", "unknown key `alpha`"),
|
||||
] {
|
||||
let e = parse_body(spec).err().unwrap_or_else(|| panic!("{spec} was accepted"));
|
||||
assert!(e.contains(expect), "{spec}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `--section` spec is the plane `inspect.section` cuts with: an axis and a world
|
||||
/// coordinate, a named plane, `flip` for the other side, or the command's own JSON.
|
||||
#[test]
|
||||
fn a_section_spec_is_the_plane_inspect_section_takes() {
|
||||
let p = |s: &str| parse_section(s).unwrap();
|
||||
// The plane normal to each axis, cutting at that coordinate.
|
||||
assert_eq!(p("z=27"), json!({"plane": "XY", "at": 27.0}));
|
||||
assert_eq!(p("y=-5.5"), json!({"plane": "XZ", "at": -5.5}));
|
||||
assert_eq!(p("X=0"), json!({"plane": "YZ", "at": 0.0}));
|
||||
assert_eq!(p("z=27,flip"), json!({"plane": "XY", "at": 27.0, "flip": true}));
|
||||
// A named plane through the origin, and the command's own parameters.
|
||||
assert_eq!(p("XY"), json!({"plane": "XY"}));
|
||||
assert_eq!(p("yz,flip"), json!({"plane": "YZ", "flip": true}));
|
||||
assert_eq!(p("{\"plane\": \"XY\", \"offset\": 4}"), json!({"plane": "XY", "offset": 4}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_section_spec_says_what_it_takes() {
|
||||
for (spec, expect) in [
|
||||
("27", "z=27"),
|
||||
("w=4", "unknown axis `w`"),
|
||||
("z=high", "is not a number"),
|
||||
("z=inf", "not a finite coordinate"),
|
||||
("z=27,upside-down", "unknown `upside-down`"),
|
||||
("{\"plane\":", "not JSON"),
|
||||
] {
|
||||
let e = parse_section(spec).err().unwrap_or_else(|| panic!("{spec} was accepted"));
|
||||
assert!(e.contains(expect), "{spec}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool colour is the one the options document, in the form `appearance.assign` takes.
|
||||
#[test]
|
||||
fn the_tool_colour_is_a_hex_colour_the_command_takes() {
|
||||
assert_eq!(parse_body(&format!("Tool:color={TOOL_COLOR}")).unwrap().color, Some(TOOL_COLOR.to_owned()));
|
||||
assert!((0.0..1.0).contains(&TARGET_OPACITY), "a target must be see-through: {TARGET_OPACITY}");
|
||||
assert_eq!(default_body_hex(), "#b0bac6");
|
||||
}
|
||||
}
|
||||
@@ -350,7 +350,7 @@ fn actions(s: &Session) -> Vec<Action> {
|
||||
// The surface's normal at `p` (either way round): exact on analytic faces.
|
||||
let normal_at = |f: u32, p: Vec3| -> Option<Vec3> {
|
||||
match face_surface(&b.body, f as usize) {
|
||||
Some(FaceSurface::Plane { normal, .. }) => Some(normal),
|
||||
Some(FaceSurface::Plane { normal, .. }) => Some(*normal),
|
||||
Some(FaceSurface::Cylinder { origin, axis, .. }) => {
|
||||
let v = p - origin;
|
||||
(v - axis * v.dot(axis)).normalized()
|
||||
@@ -1665,7 +1665,6 @@ fn a_tangent_plane_on_a_drill_tip() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #568 is fixed"]
|
||||
fn a_plane_on_a_patterned_counterbore_after_a_thickness_change() {
|
||||
let mut c = vec![json!({"command": "parameters.change", "params": {"name": "thick", "expression": "10 mm"}})];
|
||||
c.extend(rectangle(json!("XY"), [0., 0.], [80., 30.]));
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! The bracket example must cut two through holes, rather than successfully cutting empty
|
||||
//! space beside the body. Check the shipped script and rebuild its saved design from scratch.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
use solidcraft_engine::{Session, io, kernel};
|
||||
|
||||
fn assert_two_through_holes(session: &Session, stage: &str) {
|
||||
let errors: Vec<_> = session.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| (&r.name, e))).collect();
|
||||
assert!(errors.is_empty(), "{stage}: every example feature evaluates: {errors:?}");
|
||||
|
||||
let cut = session.doc.features.last().expect("the example ends with the hole cut");
|
||||
let before = session.model.state_before(cut.id);
|
||||
let after = session.model.state();
|
||||
assert_eq!(before.bodies.len(), 1, "{stage}: one bracket before cutting the holes");
|
||||
assert_eq!(after.bodies.len(), 1, "{stage}: the holes leave one bracket");
|
||||
let uncut = &before.body("Bracket").expect("the uncut bracket").body;
|
||||
let bracket = &after.body("Bracket").expect("the finished bracket").body;
|
||||
|
||||
// Measure the actual state before the final cut so the existing edge fillet cancels out.
|
||||
// Both holes have diameter 8 mm and pass through the 6 mm base. Kernel measurements are
|
||||
// tessellated, so the relative tolerance applies to the removed material, not the body.
|
||||
let removed = kernel::measure(uncut).unwrap().volume - kernel::measure(bracket).unwrap().volume;
|
||||
let expected = 2.0 * PI * 4.0_f64.powi(2) * 6.0;
|
||||
assert!((removed - expected).abs() < expected * 1e-3, "{stage}: the two holes remove {removed} mm³, expected {expected} mm³");
|
||||
|
||||
let before_mesh = uncut.tessellate(0.01).unwrap();
|
||||
let after_mesh = bracket.tessellate(0.01).unwrap();
|
||||
let down = Vec3::new(0.0, 0.0, -1.0);
|
||||
for y in [-15.0, -45.0] {
|
||||
let center = Vec3::new(25.0, y, 1.0);
|
||||
assert_eq!(before_mesh.ray_crossings(center, down), 2, "{stage}: hole at y={y} starts over the solid base");
|
||||
// Rays through each opening must miss the entire bracket, including its top and
|
||||
// bottom caps. Points away from the centre guard against a narrow slit or shifted cut.
|
||||
for (dx, dy) in [(0.0, 0.0), (3.0, 0.0), (-3.0, 0.0), (0.0, 3.0), (0.0, -3.0)] {
|
||||
let ray = center + Vec3::new(dx, dy, 0.0);
|
||||
assert_eq!(after_mesh.ray_crossings(ray, down), 0, "{stage}: the hole at y={y} is open through both caps at ({dx}, {dy})");
|
||||
}
|
||||
// Material just outside each radius remains, so neither opening is an edge notch.
|
||||
for (dx, dy) in [(5.0, 0.0), (-5.0, 0.0), (0.0, 5.0), (0.0, -5.0)] {
|
||||
let ray = center + Vec3::new(dx, dy, 0.0);
|
||||
assert_eq!(after_mesh.ray_crossings(ray, down), 2, "{stage}: material remains around the hole at y={y}, offset ({dx}, {dy})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracket_example_cuts_two_through_holes_and_reopens() {
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/bracket.json")).unwrap();
|
||||
let mut session = Session::default();
|
||||
session.run_script(&script).unwrap();
|
||||
assert_two_through_holes(&session, "script");
|
||||
|
||||
// No geometry cache is supplied: reopening the container re-evaluates the saved timeline.
|
||||
let saved = io::write_design_container(&session.doc).unwrap();
|
||||
let document = io::read_design(&saved).unwrap();
|
||||
assert_eq!(document.features, session.doc.features, "saving preserves the example's feature parameters");
|
||||
let reopened = Session::new(document);
|
||||
assert_two_through_holes(&reopened, "reopened");
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const KNOWN: [(&str, &str, &str); 2] = [
|
||||
(
|
||||
"m0-revolve-primitives",
|
||||
"Body1 bbox",
|
||||
"partial revolves turn toward the sketch normal since ac43365 (as Fusion's do); M0 turned them the other way",
|
||||
"partial revolves turn toward the sketch normal since ac43365 (as most parametric CAD tools do); M0 turned them the other way",
|
||||
),
|
||||
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! `mcp --in script --param-check <level>`: the level applies to the script the session starts
|
||||
//! from too, so a misspelled key in it is refused (strict) or reported on stderr (warn) instead of
|
||||
//! silently ignored.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn param_check_applies_to_the_starting_script() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-param-check-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let script = dir.join("typo.json");
|
||||
let box_ = r#"{"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5, "operaton": "cut"}}"#;
|
||||
std::fs::write(&script, format!(r#"{{"commands": [{box_}]}}"#)).unwrap();
|
||||
let run = |level: &str| {
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--in", script.to_str().unwrap(), "--param-check", level])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap()
|
||||
};
|
||||
let strict = run("strict");
|
||||
let stderr = String::from_utf8_lossy(&strict.stderr);
|
||||
assert!(!strict.status.success(), "{stderr}");
|
||||
assert!(stderr.contains("unknown key `operaton`; did you mean `operation`?"), "{stderr}");
|
||||
// Warn: the script runs, and its warnings go to stderr.
|
||||
let warn = run("warn");
|
||||
let stderr = String::from_utf8_lossy(&warn.stderr);
|
||||
assert!(warn.status.success(), "{stderr}");
|
||||
assert!(stderr.contains("step 1: unknown key `operaton`; did you mean `operation`?"), "{stderr}");
|
||||
// A warned step's warning is printed even when a later step fails.
|
||||
let failing = dir.join("typo_then_failure.json");
|
||||
std::fs::write(&failing, format!(r#"{{"commands": [{box_}, {{"command": "no.such_command"}}]}}"#)).unwrap();
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--in", failing.to_str().unwrap(), "--param-check", "warn"])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success(), "{stderr}");
|
||||
assert!(stderr.contains("step 1: unknown key `operaton`") && stderr.contains("step 2"), "{stderr}");
|
||||
// Off: the script runs as before, and the server ends with its input.
|
||||
let off = run("off");
|
||||
assert!(off.status.success(), "{}", String::from_utf8_lossy(&off.stderr));
|
||||
assert!(!String::from_utf8_lossy(&off.stderr).contains("operaton"));
|
||||
// A bare flag is an error, not off.
|
||||
let bare = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["mcp", "--param-check"]).stdin(Stdio::null()).output().unwrap();
|
||||
assert!(!bare.status.success());
|
||||
assert!(String::from_utf8_lossy(&bare.stderr).contains("--param-check wants off, warn or strict"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! A design opened from the model its file keeps is the design evaluated again: the same
|
||||
//! results, the same bodies (to the bit), the same face names and volumes after every feature,
|
||||
//! and the same after an edit made to both.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::{ModelBody, ModelState};
|
||||
|
||||
fn corpus() -> Vec<PathBuf> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
|
||||
let mut v: Vec<PathBuf> =
|
||||
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solidcraft")).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("solidcraft-model-cache-{}-{name}.solidcraft", std::process::id()))
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
s
|
||||
}
|
||||
|
||||
fn body(name: &str, b: &ModelBody) -> Value {
|
||||
let measured = solidcraft_engine::kernel::measure(&b.body).ok();
|
||||
json!({
|
||||
"name": b.name, "feature": b.feature, "component": b.component, "source": b.source_name, "transferred": b.transferred,
|
||||
"brep": solidcraft_engine::kernel::encode_native_body(&b.body).map(|bytes| format!("{:x}", sha(&bytes))).unwrap_or_else(|e| format!("{name}: {e}")),
|
||||
"faces": *solidcraft_engine::doc::naming::face_names(b),
|
||||
"volume": measured.as_ref().map(|m| m.volume.to_bits()), "area": measured.as_ref().map(|m| m.area.to_bits()),
|
||||
"look": b.imported.as_ref().map(|l| format!("{l:?}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn sha(bytes: &[u8]) -> u128 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
bytes.hash(&mut h);
|
||||
let a = h.finish();
|
||||
bytes.len().hash(&mut h);
|
||||
(u128::from(a) << 64) | u128::from(h.finish())
|
||||
}
|
||||
|
||||
fn state(name: &str, st: &Arc<ModelState>) -> Value {
|
||||
json!({
|
||||
"bodies": st.bodies.iter().map(|b| body(name, b)).collect::<Vec<_>>(),
|
||||
"sketches": st.sketches.iter().map(|s| json!([s.feature, s.name, s.plane, s.sketch, s.report, s.profiles.len()])).collect::<Vec<_>>(),
|
||||
"counter": st.body_counter, "sheets": st.sheets, "threads": st.threads, "construct": format!("{:?}", st.construct),
|
||||
})
|
||||
}
|
||||
|
||||
/// Everything a model holds that the rest of the app reads.
|
||||
fn model(name: &str, s: &Session) -> Value {
|
||||
let results: Vec<Value> = s
|
||||
.model
|
||||
.results
|
||||
.iter()
|
||||
.map(|r| json!({"id": r.id, "name": r.name, "error": r.error, "warning": r.warning, "skipped": r.skipped, "state": state(name, &r.state)}))
|
||||
.collect();
|
||||
json!({"results": results, "world": state(name, &s.world_state())})
|
||||
}
|
||||
|
||||
/// The same edit on both: a parameter's value when the design has one, else a new body.
|
||||
fn edit(s: &mut Session) {
|
||||
let param = s.doc.params.first().map(|p| (p.name.clone(), p.expr.clone()));
|
||||
match param {
|
||||
Some((name, expr)) => {
|
||||
s.execute("parameters.change", &json!({"name": name, "expression": format!("({expr}) * 1.05")})).unwrap();
|
||||
}
|
||||
None => {
|
||||
s.execute("solid.box", &json!({"length": 7, "width": 8, "height": 9, "operation": "new"})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check(name: &str, mut built: Session) {
|
||||
let path = temp(name);
|
||||
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(solidcraft_engine::io::read_design_geometry(&bytes).is_some(), "{name}: the save keeps the model");
|
||||
let mut cached = open(&path);
|
||||
assert_eq!(cached.model.last_recomputed, 0, "{name}: opened without evaluating");
|
||||
let mut rebuilt = Session::new(solidcraft_engine::io::read_design(&bytes).unwrap());
|
||||
assert!(rebuilt.model.last_recomputed > 0);
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: opened from the file's model");
|
||||
edit(&mut cached);
|
||||
edit(&mut rebuilt);
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: after the same edit");
|
||||
cached.execute("edit.undo", &json!({})).unwrap();
|
||||
rebuilt.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: after undoing it");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_designs_open_from_their_model_as_evaluated() {
|
||||
for p in corpus() {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let s = open(&p);
|
||||
// Names saved before format 14 are found again through names worked out from geometry on
|
||||
// demand, which a saved model does not keep.
|
||||
let legacy = s.doc.to_json().contains(&format!("\"{}", solidcraft_engine::doc::naming::LEGACY_MARK));
|
||||
if s.model.results.iter().any(|r| r.error.is_some()) || legacy {
|
||||
// Not kept: a model with errors, or names saved before format 14.
|
||||
let path = temp(&name);
|
||||
let mut s = s;
|
||||
s.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(solidcraft_engine::io::read_design_geometry(&std::fs::read(&path).unwrap()).is_none(), "{name}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
continue;
|
||||
}
|
||||
check(&name, s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_designs_open_from_their_model_as_evaluated() {
|
||||
for sample in solidcraft_engine::sample::samples() {
|
||||
let mut s = Session::default();
|
||||
for c in (sample.script)()["commands"].as_array().unwrap() {
|
||||
s.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap_or_else(|e| panic!("{}: {e}", sample.name));
|
||||
}
|
||||
check(sample.name, s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_assemblies_open_from_their_model_as_evaluated() {
|
||||
// A STEP assembly of the hinge sample: parts placed several times.
|
||||
let mut source = Session::default();
|
||||
let hinge = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Hinge Assembly").unwrap();
|
||||
for c in (hinge.script)()["commands"].as_array().unwrap() {
|
||||
source.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
let step = std::env::temp_dir().join(format!("solidcraft-model-cache-{}-hinge.step", std::process::id()));
|
||||
source.execute("file.export", &json!({"path": step.to_string_lossy()})).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("file.insert_step", &json!({"path": step.to_string_lossy()})).unwrap();
|
||||
s.execute("file.insert_step", &json!({"path": step.to_string_lossy(), "matrix": [1, 0, 0, 200, 0, 1, 0, 0, 0, 0, 1, 0]})).unwrap();
|
||||
let _ = std::fs::remove_file(&step);
|
||||
check("imported hinge", s);
|
||||
}
|
||||
|
||||
/// Opened from its model, a design evaluates only from the first feature an edit changes: the
|
||||
/// saved states before it are kept as they were read (the same states, not evaluated again).
|
||||
#[test]
|
||||
fn an_edit_after_opening_evaluates_from_the_feature_it_changes() {
|
||||
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
|
||||
let mut built = Session::default();
|
||||
for c in (plate.script)()["commands"].as_array().unwrap() {
|
||||
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
let path = temp("incremental");
|
||||
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let n = built.doc.features.len();
|
||||
assert!(n >= 4);
|
||||
let mut s = open(&path);
|
||||
assert_eq!(s.model.last_recomputed, 0);
|
||||
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
|
||||
// How many of the first features still have the states read from the file.
|
||||
let kept = |s: &Session| s.model.results.iter().zip(&read).take_while(|(r, st)| Arc::ptr_eq(&r.state, st)).count();
|
||||
// The marker moved back and forth: the features before it are as read.
|
||||
s.execute("timeline.rollback", &json!({"position": n - 2})).unwrap();
|
||||
assert_eq!(kept(&s), n - 2, "rolled back");
|
||||
s.execute("timeline.rollback", &json!({})).unwrap();
|
||||
assert_eq!(kept(&s), n, "rolled forward: the features after the marker take their saved results again");
|
||||
// Suppressing the second-to-last feature keeps every state before it.
|
||||
let k = s.doc.features[n - 2].id;
|
||||
s.execute("timeline.suppress", &json!({"feature": k, "suppressed": true})).unwrap();
|
||||
assert_eq!(kept(&s), n - 2, "evaluated from the suppressed feature on");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// A new feature at the end starts from the final state read from the file.
|
||||
let mut s = open(&path);
|
||||
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
|
||||
s.execute("solid.box", &json!({"length": 3, "width": 3, "height": 3, "operation": "new"})).unwrap();
|
||||
assert_eq!(s.model.results.len(), n + 1);
|
||||
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "only the new feature evaluated");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
}
|
||||
|
||||
/// An edit made after opening and undone finds the saved results again: undo evaluates nothing.
|
||||
#[test]
|
||||
fn undoing_an_edit_after_opening_reuses_the_saved_results() {
|
||||
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
|
||||
let mut built = Session::default();
|
||||
for c in (plate.script)()["commands"].as_array().unwrap() {
|
||||
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
let path = temp("undo");
|
||||
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let mut s = open(&path);
|
||||
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
|
||||
let first = s.doc.features[1].id;
|
||||
s.execute("timeline.suppress", &json!({"feature": first, "suppressed": true})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.model.last_recomputed, 0, "taken from the saved results");
|
||||
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "the very states read from the file");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
|
||||
}
|
||||
@@ -84,3 +84,42 @@ fn corpus_designs_replay_from_their_scripts() {
|
||||
}
|
||||
assert!(replayed >= 7, "replayed {replayed} corpus designs");
|
||||
}
|
||||
|
||||
/// #1990: a sketch and its extrude may share a name, and the design's script still replays,
|
||||
/// keeping the names: the replayed design's own script has the same steps.
|
||||
#[test]
|
||||
fn a_design_naming_a_sketch_and_its_extrude_alike_replays_from_its_script() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-shared-names-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cli = |args: &[&std::ffi::OsStr]| {
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(args).output().unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{}: {}",
|
||||
args.iter().map(|a| a.to_string_lossy()).collect::<Vec<_>>().join(" "),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
};
|
||||
let script = dir.join("mini.json");
|
||||
let saved = dir.join("mini.solidcraft");
|
||||
let exported = dir.join("rt.json");
|
||||
let replayed = dir.join("rt.solidcraft");
|
||||
let again = dir.join("rt2.json");
|
||||
let commands = json!({"commands": [
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Block"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [20, 10]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": "5", "name": "Block"}},
|
||||
]});
|
||||
std::fs::write(&script, commands.to_string()).unwrap();
|
||||
cli(&["run".as_ref(), script.as_ref(), "--save".as_ref(), saved.as_ref()]);
|
||||
cli(&["script".as_ref(), saved.as_ref(), "--out".as_ref(), exported.as_ref()]);
|
||||
cli(&["run".as_ref(), exported.as_ref(), "--save".as_ref(), replayed.as_ref()]);
|
||||
cli(&["script".as_ref(), replayed.as_ref(), "--out".as_ref(), again.as_ref()]);
|
||||
let first: Value = serde_json::from_str(&std::fs::read_to_string(&exported).unwrap()).unwrap();
|
||||
let second: Value = serde_json::from_str(&std::fs::read_to_string(&again).unwrap()).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let names: Vec<&str> = first["commands"].as_array().unwrap().iter().filter_map(|c| c["params"]["feature"]["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Block", "Block"], "{first:#}");
|
||||
assert_eq!(second["commands"], first["commands"], "the replayed design's script has the same steps");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! `snapshot --body`, `--tool` and `--section`: a picture of a tool body inside the material it
|
||||
//! meets, which is the one picture that says whether a cut reaches through.
|
||||
//!
|
||||
//! Each option is a shortcut for the command the app itself runs (`appearance.assign`,
|
||||
//! `inspect.section`), so every test here asserts that the option's picture is the picture of a
|
||||
//! script that runs those commands — not that the pixels match a stored image. Pictures are
|
||||
//! compared with a tolerance, and the tests run on whatever renderer the machine has (a GPU, a
|
||||
//! software adapter, or the CPU rasterizer), because they only ever compare two pictures this
|
||||
//! same CLI drew.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
const SIZE: (u32, u32) = (200, 140);
|
||||
|
||||
/// A PNG's size and RGBA pixels.
|
||||
fn decode(png: &[u8]) -> (u32, u32, Vec<u8>) {
|
||||
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).unwrap().to_rgba8();
|
||||
(img.width(), img.height(), img.into_raw())
|
||||
}
|
||||
|
||||
/// Pixels of two same-sized pictures that differ by more than a little in some channel.
|
||||
fn differing(a: &[u8], b: &[u8]) -> usize {
|
||||
let ((wa, ha, a), (wb, hb, b)) = (decode(a), decode(b));
|
||||
assert_eq!((wa, ha), (wb, hb), "pictures of different sizes");
|
||||
a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 16)).count()
|
||||
}
|
||||
|
||||
/// The same picture: a renderer's output may differ by a few levels or a stray edge pixel.
|
||||
#[track_caller]
|
||||
fn assert_same(a: &[u8], b: &[u8], what: &str) {
|
||||
let (n, total) = (differing(a, b), (SIZE.0 * SIZE.1) as usize);
|
||||
assert!(n * 500 <= total, "{what}: {n} of {total} pixels differ");
|
||||
}
|
||||
|
||||
/// Different pictures: more than stray pixels differ.
|
||||
#[track_caller]
|
||||
fn assert_differs(a: &[u8], b: &[u8], what: &str) {
|
||||
let n = differing(a, b);
|
||||
assert!(n >= 100, "{what}: only {n} pixels differ");
|
||||
}
|
||||
|
||||
/// How much of a picture is the tool colour (`preview::TOOL_COLOR`, #e8542c): a red neither
|
||||
/// the default body grey nor the background has, still recognisable once a see-through body in
|
||||
/// front of it has tinted it (measured on this machine: (200, 108, 85) through a target at
|
||||
/// opacity 0.3, against (164, 173, 184) for the plain grey).
|
||||
fn tool_pixels(png: &[u8]) -> usize {
|
||||
let (_, _, px) = decode(png);
|
||||
px.as_chunks::<4>().0.iter().filter(|p| p[0] > 140 && p[0] > p[1].saturating_add(50) && p[0] > p[2].saturating_add(50)).count()
|
||||
}
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("solidcraft-snapshot-appearance-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
/// A plate with a pin standing in it: the tool body a cut would remove, reaching 6 mm short
|
||||
/// of the far face, which is exactly what a picture is supposed to show and a volume is not.
|
||||
/// `more` runs on top.
|
||||
fn script(&self, name: &str, more: &[Value]) -> PathBuf {
|
||||
let mut steps = vec![
|
||||
json!({"command": "solid.box", "params": {"length": 50, "width": 34, "height": 20, "center": [0, 0, 10], "body_name": "Target"}}),
|
||||
json!({"command": "solid.cylinder", "params": {"diameter": 12, "height": 14, "base": [0, 0, 0], "operation": "new_body", "body_name": "Tool"}}),
|
||||
];
|
||||
steps.extend(more.iter().cloned());
|
||||
let path = self.0.join(name);
|
||||
std::fs::write(&path, json!({"commands": steps}).to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn run(&self, script: &Path, out: &str, extra: &[&str]) -> Output {
|
||||
let (w, h) = (SIZE.0.to_string(), SIZE.1.to_string());
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.arg("snapshot")
|
||||
.arg(script)
|
||||
.arg("--out")
|
||||
.arg(self.0.join(out))
|
||||
.args(["--width", &w, "--height", &h, "--no-grid", "--no-axes"])
|
||||
.args(extra)
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The PNG `snapshot` writes.
|
||||
fn snapshot(&self, script: &Path, out: &str, extra: &[&str]) -> Vec<u8> {
|
||||
let output = self.run(script, out, extra);
|
||||
assert!(output.status.success(), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
let png = std::fs::read(self.0.join(out)).unwrap();
|
||||
assert!(png.starts_with(b"\x89PNG"), "{extra:?}");
|
||||
png
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `--body NAME:opacity=…` draws that body see-through, and the picture is the one a script
|
||||
/// gets by assigning the same appearance itself: the option is the command, not a second way
|
||||
/// of drawing.
|
||||
#[test]
|
||||
fn a_body_drawn_see_through_is_the_picture_of_the_same_appearance_assigned() {
|
||||
let dir = Scratch::new();
|
||||
let plain = dir.script("plain.json", &[]);
|
||||
let assigned =
|
||||
dir.script("assigned.json", &[json!({"command": "appearance.assign", "params": {"bodies": ["Target"], "color": "#b0bac6", "opacity": 0.3}})]);
|
||||
let solid = dir.snapshot(&plain, "solid.png", &[]);
|
||||
let see_through = dir.snapshot(&plain, "see-through.png", &["--body", "Target:opacity=0.3"]);
|
||||
assert_differs(&see_through, &solid, "--body opacity was ignored");
|
||||
assert_same(&see_through, &dir.snapshot(&assigned, "by-script.png", &[]), "the same appearance assigned by a script");
|
||||
|
||||
// The option's colour is the design's too. It names the target, not the tool: the tool is
|
||||
// inside the target, so a solid target hides whatever colour the tool is given.
|
||||
let painted =
|
||||
dir.script("painted.json", &[json!({"command": "appearance.assign", "params": {"bodies": ["Target"], "color": "#00aa33", "opacity": 1.0}})]);
|
||||
let by_option = dir.snapshot(&plain, "painted.png", &["--body", "Target:color=#00aa33"]);
|
||||
assert_differs(&by_option, &solid, "--body color was ignored");
|
||||
assert_same(&by_option, &dir.snapshot(&painted, "painted-script.png", &[]), "the same colour assigned by a script");
|
||||
}
|
||||
|
||||
/// `--tool NAME` is the whole point: the tool solid in the tool colour, every other body
|
||||
/// see-through, so the tool shows inside the material it meets. The tool's colour covers more
|
||||
/// of the picture than it does without the option, because the target no longer hides it.
|
||||
#[test]
|
||||
fn a_tool_shows_through_the_bodies_around_it() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let plain = dir.snapshot(&script, "plain.png", &["--view", "front"]);
|
||||
let preview = dir.snapshot(&script, "preview.png", &["--view", "front", "--tool", "Tool"]);
|
||||
assert_differs(&preview, &plain, "--tool was ignored");
|
||||
// Seen from the front the plate hides the pin completely; the preview shows all of it.
|
||||
assert_eq!(tool_pixels(&plain), 0, "nothing is the tool colour before the option");
|
||||
assert!(tool_pixels(&preview) > 200, "the tool shows through the target: {} pixels", tool_pixels(&preview));
|
||||
|
||||
// It is the two commands it stands for, run in that order.
|
||||
let by_script = dir.script(
|
||||
"by-script.json",
|
||||
&[
|
||||
json!({"command": "appearance.assign", "params": {"bodies": ["Target"], "color": "#b0bac6", "opacity": 0.3}}),
|
||||
json!({"command": "appearance.assign", "params": {"bodies": ["Tool"], "color": "#e8542c", "opacity": 1.0}}),
|
||||
],
|
||||
);
|
||||
assert_same(&preview, &dir.snapshot(&by_script, "script.png", &["--view", "front"]), "the appearances --tool assigns");
|
||||
|
||||
// A later --body overrides what --tool said.
|
||||
let overridden = dir.snapshot(&script, "override.png", &["--view", "front", "--tool", "Tool", "--body", "Tool:color=#2255cc"]);
|
||||
assert!(tool_pixels(&overridden) < 50, "--body after --tool decides the tool's colour");
|
||||
}
|
||||
|
||||
/// `--section` cuts the model where `inspect.section` would, and the same spec written as the
|
||||
/// command's own JSON, or as the command in a script, gives the same picture.
|
||||
#[test]
|
||||
fn a_section_cuts_the_model_as_inspect_section_does() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let whole = dir.snapshot(&script, "whole.png", &[]);
|
||||
let cut = dir.snapshot(&script, "cut.png", &["--section", "z=12"]);
|
||||
assert_differs(&cut, &whole, "--section was ignored");
|
||||
assert_same(&cut, &dir.snapshot(&script, "cut-json.png", &["--section", r#"{"plane": "XY", "at": 12}"#]), "the command's own JSON");
|
||||
let by_script = dir.script("sectioned.json", &[json!({"command": "inspect.section", "params": {"plane": "XY", "at": 12}})]);
|
||||
assert_same(&cut, &dir.snapshot(&by_script, "by-script.png", &[]), "inspect.section in a script");
|
||||
|
||||
// The other side of the same plane is a different picture.
|
||||
let flipped = dir.snapshot(&script, "flipped.png", &["--section", "z=12,flip"]);
|
||||
assert_differs(&flipped, &cut, "flip keeps the other side");
|
||||
|
||||
// A section through a see-through target still shows the tool inside it.
|
||||
let both = dir.snapshot(&script, "both.png", &["--section", "z=12", "--tool", "Tool"]);
|
||||
assert_differs(&both, &cut, "--tool and --section together");
|
||||
assert!(tool_pixels(&both) > 100, "the tool is in the cut picture");
|
||||
}
|
||||
|
||||
/// Every way of getting the options wrong is an error that says what it takes, and writes no
|
||||
/// picture: a snapshot that quietly ignored an option would be a lie about the design.
|
||||
#[test]
|
||||
fn a_body_tool_or_section_that_cannot_be_used_is_an_error_and_writes_nothing() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let cases: [(&[&str], &str); 8] = [
|
||||
(&["--body", "Nowhere:opacity=0.3"], "no body `Nowhere`"),
|
||||
(&["--body", "Target"], "NAME:key=value"),
|
||||
(&["--body", "Target:opacity=3"], "0 to 1"),
|
||||
(&["--body", "Target:alpha=0.3"], "unknown key `alpha`"),
|
||||
(&["--body", "Target:color=red"], "#rrggbb"),
|
||||
(&["--tool", "Nowhere"], "no body `Nowhere`"),
|
||||
(&["--section", "q=1"], "unknown axis `q`"),
|
||||
(&["--section", "nonsense"], "z=27"),
|
||||
];
|
||||
for (extra, expect) in cases {
|
||||
let output = dir.run(&script, "never.png", extra);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
|
||||
assert!(stderr.contains(expect), "{extra:?}: {stderr}");
|
||||
assert!(output.stdout.is_empty() && !dir.0.join("never.png").exists(), "{extra:?}");
|
||||
}
|
||||
// An error naming a body says which bodies the design has.
|
||||
let stderr = String::from_utf8_lossy(&dir.run(&script, "never.png", &["--tool", "Nowhere"]).stderr).to_string();
|
||||
assert!(stderr.contains("Target") && stderr.contains("Tool"), "{stderr}");
|
||||
}
|
||||
|
||||
/// The usage text says what the options do.
|
||||
#[test]
|
||||
fn the_usage_names_the_body_tool_and_section_options() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("snapshot").output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
for word in ["--body", "--tool", "--section", "appearance.assign", "inspect.section", "see-through"] {
|
||||
assert!(stderr.contains(word), "usage lacks {word}: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,10 @@ mod web {
|
||||
/// On-demand CJK font: fetched only when a CJK language is selected.
|
||||
font: solidcraft_ui_egui::fonts::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
/// The fetched CJK font's bytes: kept so a language change can re-order the fallbacks.
|
||||
cjk_bytes: Option<Vec<u8>>,
|
||||
/// The language the fonts were last set up for (a CJK UI leads with the CJK face).
|
||||
font_language: String,
|
||||
space_mouse: SpaceMouse,
|
||||
}
|
||||
|
||||
@@ -375,7 +379,10 @@ mod web {
|
||||
let done = self.font_result.borrow_mut().take();
|
||||
if let Some(res) = done {
|
||||
let installed = match res {
|
||||
Ok(bytes) => solidcraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
|
||||
Ok(bytes) => {
|
||||
self.cjk_bytes = Some(bytes);
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
|
||||
false
|
||||
@@ -387,11 +394,29 @@ mod web {
|
||||
log::warn!("CJK font unavailable; showing English for this session");
|
||||
}
|
||||
self.font.finished(installed, &mut self.app.preferences.language);
|
||||
self.apply_fonts(ctx);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if self.font.tick(&mut self.app.preferences.language) {
|
||||
self.fetch_font(ctx);
|
||||
}
|
||||
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
|
||||
// to Ukrainian (or English) must put the Latin face back in front.
|
||||
if self.app.preferences.language != self.font_language {
|
||||
self.apply_fonts(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up the UI fonts for the live language, keeping the fetched CJK font as a fallback
|
||||
/// (it leads only for a CJK UI, see [`solidcraft_ui_egui::fonts::install_cjk_font_for`]).
|
||||
fn apply_fonts(&mut self, ctx: &egui::Context) {
|
||||
match self.cjk_bytes.clone() {
|
||||
Some(bytes) => {
|
||||
solidcraft_ui_egui::fonts::install_cjk_font_for(ctx, bytes, &self.app.preferences.language);
|
||||
}
|
||||
None => solidcraft_ui_egui::fonts::setup_fonts(ctx),
|
||||
}
|
||||
self.font_language.clone_from(&self.app.preferences.language);
|
||||
}
|
||||
|
||||
/// CJK text on screen with no CJK font yet (an English UI showing a Japanese name):
|
||||
@@ -549,6 +574,7 @@ mod web {
|
||||
app.home.show(rev);
|
||||
}
|
||||
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("SolidCraft browser API: {e:?}"))?;
|
||||
let font_language = app.preferences.language.clone();
|
||||
Ok(Box::new(Shell {
|
||||
app,
|
||||
api,
|
||||
@@ -560,6 +586,8 @@ mod web {
|
||||
recents_synced: false,
|
||||
font: Default::default(),
|
||||
font_result: Rc::new(std::cell::RefCell::new(None)),
|
||||
cjk_bytes: None,
|
||||
font_language,
|
||||
space_mouse: SpaceMouse::default(),
|
||||
}))
|
||||
}),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! navigation takes them with 3DxWare's object-mode mapping, as the Linux app does without a
|
||||
//! profile.
|
||||
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, FULL_SCALE};
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, Calibration, FULL_SCALE, calibrated};
|
||||
|
||||
/// Logitech's vendor id, which older 3Dconnexion devices carry, and those devices' product ids
|
||||
/// (usb.ids). Logitech's own gamepads and wheels carry the same vendor id.
|
||||
@@ -60,9 +60,43 @@ pub fn axes(values: &[f64]) -> Option<Axes> {
|
||||
Some(Axes { translation: [raw(0)?, raw(1)?, raw(2)?], rotation: [raw(3)?, raw(4)?, raw(5)?] })
|
||||
}
|
||||
|
||||
/// What a calibration of the gamepad with this id is saved under: `web` and its vendor and
|
||||
/// product ids.
|
||||
pub fn device_key(id: &str) -> Option<String> {
|
||||
vendor_product(id).map(|(vendor, product)| format!("web {vendor:04x}:{product:04x}"))
|
||||
}
|
||||
|
||||
/// The 3D mouse to read among the ones listed: the first device, else the first joystick.
|
||||
pub fn choose(found: impl IntoIterator<Item = (Source, Axes)>) -> Option<Axes> {
|
||||
found.into_iter().min_by_key(|(source, _)| *source).map(|(_, axes)| axes)
|
||||
pub fn choose<T>(found: impl IntoIterator<Item = (Source, T)>) -> Option<T> {
|
||||
found.into_iter().min_by_key(|(source, _)| *source).map(|(_, reading)| reading)
|
||||
}
|
||||
|
||||
/// Whether the cap is moving as the next frame will see it: through the device's calibration
|
||||
/// (as of the last frame) when it has one. The idle page wakes for a short-range device's push,
|
||||
/// and not for a device resting off zero; the wheel filter goes by the same.
|
||||
pub fn moving(axes: Axes, calibration: Option<Calibration>) -> bool {
|
||||
calibrated(axes, calibration).moving()
|
||||
}
|
||||
|
||||
/// The calibration as of the last frame, and the device it was for.
|
||||
pub type Cached = (Option<String>, Option<Calibration>);
|
||||
|
||||
/// Whether the cap of `device` is moving, judged with the calibration kept from the last frame:
|
||||
/// `None` when that was for another device, so another's calibration never judges this one. The
|
||||
/// waker then lets a frame look, which brings the cache up to date.
|
||||
pub fn moving_as_cached(device: &Option<String>, axes: Axes, cached: &Cached) -> Option<bool> {
|
||||
(*device == cached.0).then(|| moving(axes, cached.1))
|
||||
}
|
||||
|
||||
/// Whether the idle page's timer redraws: when the cap is moving as the last frame's calibration
|
||||
/// judges it, when the device read isn't the one that calibration was for (a frame then judges
|
||||
/// it with its own), and once when the device the last frame read is gone, so the page shows
|
||||
/// that. With no device, and none last frame, it stays quiet.
|
||||
pub fn wakes(reading: Option<(Option<String>, Axes)>, cached: &Cached) -> bool {
|
||||
match reading {
|
||||
Some((device, axes)) => moving_as_cached(&device, axes, cached).unwrap_or(true),
|
||||
None => cached.0.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -70,29 +104,29 @@ pub use browser::SpaceMouse;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod browser {
|
||||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, DriverMapping, navigate_frame};
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, DriverMapping, calibration, navigate_frame};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
use solidcraft_ui_egui::space_mouse::WheelGuard;
|
||||
|
||||
use super::{axes, choose, source};
|
||||
use super::{Cached, axes, choose, device_key, moving_as_cached, source, wakes};
|
||||
|
||||
/// How often the page looks for the cap starting to move while nothing else redraws.
|
||||
const WAKE_MS: i32 = 50;
|
||||
|
||||
/// The 3D mouse the browser lists, if any.
|
||||
fn read() -> Option<Axes> {
|
||||
/// The 3D mouse the browser lists, if any: its calibration key and its axes.
|
||||
fn read() -> Option<(Option<String>, Axes)> {
|
||||
let pads = web_sys::window()?.navigator().get_gamepads().ok()?;
|
||||
choose(pads.iter().filter_map(|pad| {
|
||||
let pad = pad.dyn_into::<web_sys::Gamepad>().ok().filter(web_sys::Gamepad::connected)?;
|
||||
let source = source(&pad.id())?;
|
||||
let values: Vec<f64> = pad.axes().iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect();
|
||||
Some((source, axes(&values)?))
|
||||
Some((source, (device_key(&pad.id()), axes(&values)?)))
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -100,6 +134,8 @@ mod browser {
|
||||
#[derive(Default)]
|
||||
pub struct SpaceMouse {
|
||||
axes: Axes,
|
||||
/// The 3D mouse being read, as `SolveApp::space_mouse` keeps it.
|
||||
device: Option<String>,
|
||||
/// The previous frame's time and the step since it (seconds).
|
||||
last: Option<f64>,
|
||||
seconds: f64,
|
||||
@@ -111,6 +147,8 @@ mod browser {
|
||||
waking: bool,
|
||||
/// Navigation is on (the waker reads it).
|
||||
enabled: Rc<Cell<bool>>,
|
||||
/// The calibration as of the last frame and its device (the waker and the wheel filter read it).
|
||||
cached: Rc<RefCell<Cached>>,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
@@ -131,14 +169,18 @@ mod browser {
|
||||
}
|
||||
let reading = read();
|
||||
if reading.is_some() && !self.waking {
|
||||
self.waking = wake(ctx, &self.enabled);
|
||||
self.waking = wake(ctx, &self.enabled, &self.cached);
|
||||
}
|
||||
// Inside the dead zone is rest: a cap resting just off zero doesn't keep redrawing.
|
||||
self.axes = reading.filter(|axes| axes.moving()).unwrap_or_default();
|
||||
self.wheel.filter(self.axes.moving(), now, &mut raw.events);
|
||||
// As read: calibration needs the readings at rest too, and `navigate_frame` leaves a
|
||||
// cap resting inside the dead zone alone.
|
||||
(self.device, self.axes) = reading.unwrap_or_default();
|
||||
let moving = moving_as_cached(&self.device, self.axes, &self.cached.borrow()).unwrap_or_else(|| self.axes.moving());
|
||||
self.wheel.filter(moving, now, &mut raw.events);
|
||||
}
|
||||
|
||||
pub fn logic(&self, app: &mut SolveApp, ctx: &egui::Context) {
|
||||
app.space_mouse.device.clone_from(&self.device);
|
||||
*self.cached.borrow_mut() = (self.device.clone(), calibration(app));
|
||||
navigate_frame(app, ctx, DriverMapping::object_mode(), self.axes, self.seconds);
|
||||
}
|
||||
}
|
||||
@@ -156,11 +198,11 @@ mod browser {
|
||||
|
||||
/// The browser sends no event when a gamepad moves, so a timer looks while the app is idle and
|
||||
/// redraws when the cap starts moving; the frames that follow keep going on their own.
|
||||
fn wake(ctx: &egui::Context, enabled: &Rc<Cell<bool>>) -> bool {
|
||||
fn wake(ctx: &egui::Context, enabled: &Rc<Cell<bool>>, cached: &Rc<RefCell<Cached>>) -> bool {
|
||||
let Some(window) = web_sys::window() else { return false };
|
||||
let (ctx, enabled) = (ctx.clone(), Rc::clone(enabled));
|
||||
let (ctx, enabled, cached) = (ctx.clone(), Rc::clone(enabled), Rc::clone(cached));
|
||||
let tick = Closure::<dyn FnMut()>::new(move || {
|
||||
if enabled.get() && read().is_some_and(Axes::moving) {
|
||||
if enabled.get() && wakes(read(), &cached.borrow()) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
@@ -192,6 +234,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calibration_is_saved_under_the_ids_whichever_browser_lists_it() {
|
||||
assert_eq!(device_key("SpaceMouse Pro (Vendor: 046d Product: c62b)").as_deref(), Some("web 046d:c62b"));
|
||||
assert_eq!(device_key("46d-c62b-SpaceMouse Pro").as_deref(), Some("web 046d:c62b"));
|
||||
assert_eq!(device_key("Xbox Wireless Controller"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_idle_page_wakes_for_motion_as_calibrated_not_as_read() {
|
||||
let axis =
|
||||
|source, rest, full| solidcraft_ui_egui::space_mouse::AxisCalibration { source, reversed: false, rest, dead_zone: 0, full: [full; 2] };
|
||||
// A short-range device: 15 of its 50 is under the raw dead zone, but 105 of 350 calibrated.
|
||||
let short = Calibration { dead_zone_share: 0.0, axes: std::array::from_fn(|r| axis(r, 0, 50)) };
|
||||
let push = Axes { translation: [15, 0, 0], rotation: [0; 3] };
|
||||
assert!(!push.moving() && moving(push, Some(short)));
|
||||
// A device resting at 100 on every axis: past the raw dead zone, but at rest calibrated.
|
||||
let offset = Calibration { dead_zone_share: 0.0, axes: std::array::from_fn(|r| axis(r, 100, 350)) };
|
||||
let resting = Axes { translation: [100; 3], rotation: [100; 3] };
|
||||
assert!(resting.moving() && !moving(resting, Some(offset)));
|
||||
// Without a calibration, as read.
|
||||
assert!(!moving(push, None) && moving(resting, None));
|
||||
// Kept from the last frame: only for the device it was for.
|
||||
let (a, b) = (Some("web 256f:c63a".to_string()), Some("web 1234:5678".to_string()));
|
||||
assert_eq!(moving_as_cached(&b, push, &(b.clone(), Some(short))), Some(true));
|
||||
assert_eq!(moving_as_cached(&b, push, &(a.clone(), Some(offset))), None, "A's calibration doesn't judge B");
|
||||
assert_eq!(moving_as_cached(&b, push, &(None, None)), None);
|
||||
assert_eq!(moving_as_cached(&a, resting, &(a.clone(), None)), Some(true), "A was read without a calibration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_idle_page_wakes_once_for_a_device_change_and_stays_quiet_without_one() {
|
||||
let (a, b) = (Some("web 256f:c63a".to_string()), Some("web 1234:5678".to_string()));
|
||||
let still = Axes::default();
|
||||
let last_frame_read_a: Cached = (a.clone(), None);
|
||||
assert!(!wakes(Some((a.clone(), still)), &last_frame_read_a), "A, at rest");
|
||||
assert!(wakes(Some((b.clone(), still)), &last_frame_read_a), "A to B: a frame looks at B");
|
||||
assert!(wakes(None, &last_frame_read_a), "A to none: a frame shows it's gone");
|
||||
assert!(!wakes(None, &(None, None)), "none to none: quiet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepads_and_other_logitech_devices_are_not_3d_mice() {
|
||||
for id in [
|
||||
@@ -280,6 +362,6 @@ mod tests {
|
||||
assert_eq!(choose([(Source::VirtualJoystick, other)]), Some(other));
|
||||
// Two devices: the first listed.
|
||||
assert_eq!(choose([(Source::Device, moving), (Source::Device, other)]), Some(moving));
|
||||
assert_eq!(choose([]), None);
|
||||
assert_eq!(choose::<Axes>([]), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ impl eframe::App for App {
|
||||
fn on_exit(&mut self) {
|
||||
// The agent chat's agent and everything it started end with the app.
|
||||
solidcraft_ui_egui::agent_chat::shutdown(&mut self.solve);
|
||||
// A save still being written ends before the app does.
|
||||
solidcraft_ui_egui::save_job::tick(&mut self.solve, true);
|
||||
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
solidcraft_engine::crash::end_session();
|
||||
// An update asked to restart into the new version or to run its installer.
|
||||
@@ -179,7 +181,8 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
}
|
||||
}
|
||||
let all: Vec<&str> = all.iter().map(String::as_str).collect();
|
||||
let future = file_parent.clone().add_filter(label, &all).pick_file();
|
||||
// Any other file attaches by reference, so the list is not the only choice.
|
||||
let future = file_parent.clone().add_filter(label, &all).add_filter("All files", &["*"]).pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
pick_program: Some(Box::new(move || {
|
||||
|
||||
@@ -19,7 +19,7 @@ use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use solidcraft_space_mouse::Layout;
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame};
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, Calibration, DriverMapping, WheelGuard, calibrated, calibration, navigate_frame};
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
/// Whether the 3Dconnexion driver (macOS) has the device: it then reports the cap, and reading
|
||||
@@ -165,8 +165,36 @@ fn load_driver_mapping() -> Option<DriverMapping> {
|
||||
parse_driver_mapping(&xml)
|
||||
}
|
||||
|
||||
/// Where the reading comes from, which a calibration is saved under (`Source::key`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Source {
|
||||
/// The device itself, with its vendor and product ids when known.
|
||||
Hid(Option<(u16, u16)>),
|
||||
/// spacenavd, which may have swapped or inverted axes on its own (`spnavcfg`).
|
||||
#[cfg(target_os = "linux")]
|
||||
Spacenavd,
|
||||
/// The 3Dconnexion driver (macOS), which applies its own settings first.
|
||||
#[cfg(target_os = "macos")]
|
||||
Driver,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
fn key(self) -> String {
|
||||
match self {
|
||||
Self::Hid(Some((vendor, product))) => format!("hid {vendor:04x}:{product:04x}"),
|
||||
Self::Hid(None) => "hid".into(),
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Spacenavd => "spacenavd".into(),
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::Driver => "3dxware".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Reading {
|
||||
/// The device being read; none between devices.
|
||||
device: Option<Source>,
|
||||
axes: Axes,
|
||||
/// When each of X, Y, Z, Rx, Ry and Rz last arrived. Each ages on its own: a device may send
|
||||
/// translation and rotation in separate reports, or spread even one group over several.
|
||||
@@ -179,15 +207,18 @@ struct Reading {
|
||||
|
||||
impl Reading {
|
||||
fn new(now: Instant) -> Self {
|
||||
Self { axes: Axes::default(), at: [now; 6], held: false }
|
||||
Self { device: None, axes: Axes::default(), at: [now; 6], held: false }
|
||||
}
|
||||
|
||||
fn current(self, now: Instant) -> Axes {
|
||||
/// The axes at `now`. One with nothing fresh reads as `rest`: the device's own rest when it
|
||||
/// is calibrated, so a device that rests off zero calibrates to no motion rather than drifting.
|
||||
fn current(self, now: Instant, rest: Axes) -> Axes {
|
||||
let mut axes = self.axes;
|
||||
if !self.held {
|
||||
for (value, at) in axes.translation.iter_mut().chain(axes.rotation.iter_mut()).zip(self.at) {
|
||||
let rest = rest.translation.into_iter().chain(rest.rotation);
|
||||
for ((value, at), rest) in axes.translation.iter_mut().chain(axes.rotation.iter_mut()).zip(self.at).zip(rest) {
|
||||
if now.duration_since(at) >= STALE_AFTER {
|
||||
*value = 0;
|
||||
*value = rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,6 +226,11 @@ impl Reading {
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw axes at the device's rest, which calibrate to no motion; 0 without a calibration.
|
||||
fn at_rest(calibration: Option<Calibration>) -> Axes {
|
||||
calibration.map(|calibration| calibration.raw_rest().into()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The Generic Desktop "Multi-Axis Controller" usage names a 3D mouse's motion interface, whoever
|
||||
/// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
@@ -233,11 +269,22 @@ fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn publish_held(ctx: &Context, shared: &Mutex<Reading>, axes: Axes) {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
*reading = Reading { axes, held: true, ..Reading::new(Instant::now()) };
|
||||
*reading = Reading { device: reading.device, axes, held: true, ..Reading::new(Instant::now()) };
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// A device is open: say which, so its calibration applies.
|
||||
fn connected(ctx: &Context, shared: &Mutex<Reading>, source: Source) {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
// Nothing has arrived from it yet, so every axis starts out stale.
|
||||
let now = Instant::now();
|
||||
let stale = now.checked_sub(STALE_AFTER).unwrap_or(now);
|
||||
*reading = Reading { device: Some(source), at: [stale; 6], ..Reading::new(now) };
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// The device is gone: stop reporting its last position.
|
||||
fn reset(ctx: &Context, shared: &Mutex<Reading>) {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
@@ -255,7 +302,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
loop {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
log::warn!("3D mouse: device scan failed: {error}");
|
||||
} else if let Some((device, layout)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| {
|
||||
} else if let Some((device, layout, ids)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| {
|
||||
let device = info
|
||||
.open_device(&api)
|
||||
.inspect_err(|error| {
|
||||
@@ -267,8 +314,9 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
})
|
||||
.ok()?;
|
||||
let layout = device_layout(&device, info.vendor_id())?;
|
||||
Some((device, layout))
|
||||
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) {
|
||||
@@ -323,6 +371,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
loop {
|
||||
if let Some(daemon) = spacenavd::connect() {
|
||||
log::info!("3D mouse: reading spacenavd, which applies the spnavcfg settings");
|
||||
connected(&ctx, &shared, Source::Spacenavd);
|
||||
spacenavd::read(daemon, |axes| publish_held(&ctx, &shared, axes));
|
||||
reset(&ctx, &shared);
|
||||
} else if let Some(device) = hidraw::find() {
|
||||
@@ -330,6 +379,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
Ok(mut file) => {
|
||||
denied = None;
|
||||
log::info!("3D mouse: reading {}", device.path.display());
|
||||
connected(&ctx, &shared, Source::Hid(device.ids));
|
||||
let mut report = [0_u8; REPORT_BUFFER_BYTES];
|
||||
loop {
|
||||
match file.read(&mut report) {
|
||||
@@ -362,25 +412,34 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
fn start_driver_client(ctx: &Context, shared: &Arc<Mutex<Reading>>) {
|
||||
use solidcraft_3dx_client::Event;
|
||||
let (ctx, shared) = (ctx.clone(), Arc::clone(shared));
|
||||
let started = solidcraft_3dx_client::start("SolidCraft", move |event| match event {
|
||||
Event::DeviceAdded => DRIVER_CONNECTED.store(true, Ordering::Relaxed),
|
||||
Event::DeviceRemoved => {
|
||||
DRIVER_CONNECTED.store(false, Ordering::Relaxed);
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
Event::Axes { translation, rotation } => {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
let now = Instant::now();
|
||||
reading.axes = Axes { translation, rotation };
|
||||
reading.at = [now; 6];
|
||||
ctx.request_repaint();
|
||||
let started = solidcraft_3dx_client::start(
|
||||
"SolidCraft",
|
||||
move |event| match event {
|
||||
Event::DeviceAdded => {
|
||||
DRIVER_CONNECTED.store(true, Ordering::Relaxed);
|
||||
connected(&ctx, &shared, Source::Driver);
|
||||
}
|
||||
}
|
||||
Event::Buttons(_) => {}
|
||||
});
|
||||
match started {
|
||||
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
|
||||
Err(error) => log::info!("3D mouse: no driver client: {error}"),
|
||||
Event::DeviceRemoved => {
|
||||
DRIVER_CONNECTED.store(false, Ordering::Relaxed);
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
Event::Axes { translation, rotation } => {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
let now = Instant::now();
|
||||
reading.axes = Axes { translation, rotation };
|
||||
reading.at = [now; 6];
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Event::Buttons(_) => {}
|
||||
},
|
||||
|connected| match connected {
|
||||
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
|
||||
Err(error) => log::info!("3D mouse: no driver client: {error}"),
|
||||
},
|
||||
);
|
||||
if let Err(error) = started {
|
||||
log::info!("3D mouse: no driver client: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +451,9 @@ pub struct SpaceMouse {
|
||||
/// Drops 3DxWare's emulated wheel while the cap moves (#1380).
|
||||
wheel: WheelGuard,
|
||||
started: Instant,
|
||||
/// The calibration as of the last frame and the device it is for, for the wheel filter, which
|
||||
/// runs before the app sees the frame.
|
||||
calibration: Option<(Source, Calibration)>,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
@@ -412,6 +474,7 @@ impl SpaceMouse {
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +488,15 @@ impl SpaceMouse {
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
let moving = self.shared.lock().map(|reading| reading.current(now).moving()).unwrap_or(false);
|
||||
let moving = self
|
||||
.shared
|
||||
.lock()
|
||||
.map(|reading| {
|
||||
// Only the calibration of the device being read: another's would misjudge it.
|
||||
let calibration = self.calibration.filter(|(source, _)| Some(*source) == reading.device).map(|(_, calibration)| calibration);
|
||||
calibrated(reading.current(now, at_rest(calibration)), calibration).moving()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
self.wheel.filter(moving, now.duration_since(self.started).as_secs_f64(), &mut raw.events);
|
||||
}
|
||||
|
||||
@@ -445,11 +516,16 @@ impl SpaceMouse {
|
||||
}
|
||||
self.mapping_checked_at = now;
|
||||
}
|
||||
let axes = match self.shared.lock() {
|
||||
Ok(reading) => reading.current(now),
|
||||
Err(_) => return,
|
||||
};
|
||||
navigate_frame(app, ctx, self.driver_mapping, axes, seconds);
|
||||
let Ok(reading) = self.shared.lock().map(|reading| *reading) else { return };
|
||||
app.space_mouse.device = reading.device.map(Source::key);
|
||||
let calibration = calibration(app);
|
||||
self.calibration = reading.device.zip(calibration);
|
||||
// While this device is being calibrated, an axis with nothing fresh reads 0, as a HID
|
||||
// device's silence means: the old calibration's rest isn't fed back into the new one. A
|
||||
// calibration of another device doesn't count; it ends in `navigate_frame`.
|
||||
let calibrating = app.space_mouse.calibrating.as_ref().is_some_and(|(device, _)| app.space_mouse.device.as_ref() == Some(device));
|
||||
let rest = if calibrating { Axes::default() } else { at_rest(calibration) };
|
||||
navigate_frame(app, ctx, self.driver_mapping, reading.current(now, rest), seconds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,14 +547,14 @@ mod tests {
|
||||
let mut reading = Reading::new(now);
|
||||
let layout = Layout::default();
|
||||
assert!(!decode_report(&[1, 1, 2], layout, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
assert_eq!(reading.current(now, Axes::default()), Axes::default());
|
||||
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], layout, &mut reading, now));
|
||||
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], layout, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [0, 1, -1] });
|
||||
assert_eq!(reading.current(now, Axes::default()), Axes { translation: [-100, 300, 0], rotation: [0, 1, -1] });
|
||||
// Only rotation arrives again: translation goes stale, rotation stays current.
|
||||
let later = now + STALE_AFTER + Duration::from_millis(1);
|
||||
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], layout, &mut reading, later));
|
||||
assert_eq!(reading.current(later), Axes { translation: [0; 3], rotation: [4, 5, 6] });
|
||||
assert_eq!(reading.current(later, Axes::default()), Axes { translation: [0; 3], rotation: [4, 5, 6] });
|
||||
// A device sending all six in one report updates both at once.
|
||||
let combined = solidcraft_space_mouse::Descriptor::parse(&[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, 0x19, 0x30, 0x29, 0x35, 0x81,
|
||||
@@ -488,7 +564,7 @@ mod tests {
|
||||
.expect("six axes declared");
|
||||
let mut both = Reading::new(now);
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0], combined, &mut both, now));
|
||||
assert_eq!(both.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
assert_eq!(both.current(now, Axes::default()), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -507,11 +583,11 @@ mod tests {
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(decode_report(&[1, 100, 0], split_x_y, &mut reading, now));
|
||||
assert!(decode_report(&[2, 200, 0], split_x_y, &mut reading, now));
|
||||
assert_eq!(reading.current(now).translation, [100, 200, 0]);
|
||||
assert_eq!(reading.current(now, Axes::default()).translation, [100, 200, 0]);
|
||||
// Then only X keeps coming: Y goes stale on its own.
|
||||
let later = now + STALE_AFTER + Duration::from_millis(1);
|
||||
assert!(decode_report(&[1, 50, 0], split_x_y, &mut reading, later));
|
||||
assert_eq!(reading.current(later).translation, [50, 0, 0]);
|
||||
assert_eq!(reading.current(later, Axes::default()).translation, [50, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -524,8 +600,8 @@ mod tests {
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(decode_report(&[1, 100, 0, 0, 0, 0, 0], layout, &mut reading, now));
|
||||
// Just before the timeout X is still there; at exactly the timeout it's stale.
|
||||
assert_eq!(reading.current(now + STALE_AFTER - Duration::from_millis(1)).translation, [100, 0, 0]);
|
||||
assert_eq!(reading.current(now + STALE_AFTER).translation, [0; 3]);
|
||||
assert_eq!(reading.current(now + STALE_AFTER - Duration::from_millis(1), Axes::default()).translation, [100, 0, 0]);
|
||||
assert_eq!(reading.current(now + STALE_AFTER, Axes::default()).translation, [0; 3]);
|
||||
// Button reports and reports with an id the layout doesn't know keep coming, but neither
|
||||
// refreshes X: it goes stale on time all the same.
|
||||
for step in 1..=10 {
|
||||
@@ -533,7 +609,7 @@ mod tests {
|
||||
assert!(decode_report(&[3, 1], layout, &mut reading, at));
|
||||
assert!(!decode_report(&[0x17, 80], layout, &mut reading, at));
|
||||
}
|
||||
assert_eq!(reading.current(now + STALE_AFTER).translation, [0; 3]);
|
||||
assert_eq!(reading.current(now + STALE_AFTER, Axes::default()).translation, [0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -543,10 +619,10 @@ mod tests {
|
||||
let later = now + STALE_AFTER * 20;
|
||||
// spacenavd sends nothing while the values stay the same: the push lasts.
|
||||
let held = Reading { axes, held: true, ..Reading::new(now) };
|
||||
assert_eq!(held.current(later), axes);
|
||||
assert_eq!(held.current(later, Axes::default()), axes);
|
||||
// A HID device repeats its report: silence means the cap, or the device, is gone.
|
||||
let hid = Reading { axes, ..Reading::new(now) };
|
||||
assert_eq!(hid.current(later), Axes::default());
|
||||
assert_eq!(hid.current(later, Axes::default()), Axes::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -618,8 +694,12 @@ mod tests {
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
calibration: None,
|
||||
};
|
||||
mouse.tick_at(&mut app, &Context::default(), now);
|
||||
// A window that has run a frame with focus, as the app's always has by now.
|
||||
let ctx = Context::default();
|
||||
let _ = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
mouse.tick_at(&mut app, &ctx, now);
|
||||
let DeviceMotion { motion, seconds } = app.viewport.device_motion.expect("the frame hands the page its motion");
|
||||
assert_eq!(motion, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, -1.0] });
|
||||
assert!(seconds > 0.0);
|
||||
@@ -642,9 +722,84 @@ mod tests {
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
|
||||
const DEVICE: Source = Source::Hid(Some((0x1234, 0x5678)));
|
||||
|
||||
/// A device whose raw axes all rest at 100, calibrated as such.
|
||||
fn resting_at_100() -> Calibration {
|
||||
Calibration {
|
||||
dead_zone_share: 0.0,
|
||||
axes: std::array::from_fn(|source| solidcraft_space_mouse::AxisCalibration {
|
||||
source,
|
||||
reversed: false,
|
||||
rest: 100,
|
||||
dead_zone: 0,
|
||||
full: [350; 2],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calibrated_device_resting_off_zero_leaves_the_wheel_alone() {
|
||||
// Read raw, X at 100 is the cap pushed past the dead zone: the wheel is dropped.
|
||||
let now = Instant::now();
|
||||
let mut resting = Reading::new(now);
|
||||
resting.device = Some(DEVICE);
|
||||
resting.axes = Axes { translation: [100; 3], rotation: [100; 3] };
|
||||
let wheel_kept = |calibration| {
|
||||
let mut mouse = mouse(resting, now);
|
||||
mouse.calibration = calibration;
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
mouse.raw_input_hook_at(true, &mut raw, now);
|
||||
raw.events.len() == 1
|
||||
};
|
||||
assert!(!wheel_kept(None));
|
||||
// Calibrated, it's at rest, so the wheel is the user's.
|
||||
assert!(wheel_kept(Some((DEVICE, resting_at_100()))));
|
||||
// Another device's calibration, kept from the last frame, isn't used for this one.
|
||||
assert!(!wheel_kept(Some((Source::Hid(Some((0x256f, 0xc63a))), resting_at_100()))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calibrated_device_resting_off_zero_doesnt_drift_when_nothing_fresh_arrives() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
|
||||
app.home.open = false;
|
||||
app.preferences.space_mouse_calibrations.insert(DEVICE.key(), resting_at_100());
|
||||
// A window that has run a frame with focus, as the app's always has by now.
|
||||
let ctx = Context::default();
|
||||
let mut frame = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
frame.textures_delta.clear();
|
||||
let motion = |app: &mut SolveApp, reading: Reading, now: Instant| {
|
||||
let mut mouse = mouse(reading, now);
|
||||
mouse.last_frame = now - Duration::from_millis(20);
|
||||
app.viewport.device_motion = None;
|
||||
mouse.tick_at(app, &ctx, now);
|
||||
app.viewport.device_motion.is_some()
|
||||
};
|
||||
// Connected, and nothing reported yet.
|
||||
let shared = Mutex::new(Reading::new(Instant::now()));
|
||||
connected(&Context::default(), &shared, DEVICE);
|
||||
let fresh = *shared.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
assert!(!motion(&mut app, fresh, now), "connected, nothing reported yet");
|
||||
// The same, while another device's calibration window was open: it ends, and this one
|
||||
// doesn't move on the first frame either.
|
||||
app.space_mouse.calibrating = Some(("hid 256f:c63a".into(), solidcraft_space_mouse::Wizard::default()));
|
||||
assert!(!motion(&mut app, fresh, now), "connected mid-calibration of another device");
|
||||
assert!(app.space_mouse.calibrating.is_none());
|
||||
// Translation reported at rest, rotation not for a while.
|
||||
let old = now - STALE_AFTER - Duration::from_millis(1);
|
||||
let partly = Reading { axes: Axes { translation: [100; 3], rotation: [0; 3] }, at: [now, now, now, old, old, old], ..fresh };
|
||||
assert!(!motion(&mut app, partly, now), "translation at rest, rotation stale");
|
||||
// Pushed, then the reports stopped.
|
||||
let pushed = Reading { axes: Axes { translation: [450, 100, 100], rotation: [100; 3] }, at: [old; 6], ..fresh };
|
||||
assert!(!motion(&mut app, pushed, now), "the push timed out");
|
||||
assert!(motion(&mut app, Reading { at: [now; 6], ..pushed }, now), "a fresh push moves the view");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_3dxware_makes_from_the_cap_is_dropped() {
|
||||
// The cap tilted forward: 3DxWare also sends wheel turns (#1380). They are dropped.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with
|
||||
//! its report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading it is plain file
|
||||
//! I/O, so no udev or hidapi library is needed.
|
||||
//! its report descriptor and ids under `/sys/class/hidraw/hidrawN/device/`. Reading them is plain
|
||||
//! file I/O, so no udev or hidapi library is needed.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -12,6 +12,8 @@ const DEV: &str = "/dev";
|
||||
pub struct Device {
|
||||
pub path: PathBuf,
|
||||
pub layout: Layout,
|
||||
/// Its vendor and product ids, when the kernel gives them.
|
||||
pub ids: Option<(u16, u16)>,
|
||||
}
|
||||
|
||||
/// The first hidraw interface that is a multi-axis controller with motion axes, whoever made it.
|
||||
@@ -25,5 +27,27 @@ fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
|
||||
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
|
||||
// A controller that declares no motion axis (buttons only, say) isn't read.
|
||||
let layout = descriptor.is_multi_axis_controller().then(|| descriptor.layout()).flatten()?;
|
||||
Some(Device { path, layout })
|
||||
let ids = std::fs::read_to_string(sysfs.join("device/uevent")).ok().as_deref().and_then(ids);
|
||||
Some(Device { path, layout, ids })
|
||||
}
|
||||
|
||||
/// The vendor and product ids in a HID device's uevent: `HID_ID=bus:vendor:product`, in hex.
|
||||
fn ids(uevent: &str) -> Option<(u16, u16)> {
|
||||
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
|
||||
let mut hex = id.split(':').skip(1).map(|part| u32::from_str_radix(part, 16).ok().and_then(|v| u16::try_from(v).ok()));
|
||||
Some((hex.next()??, hex.next()??))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ids_come_from_the_uevent() {
|
||||
let wireless = "DRIVER=hid-generic\nHID_ID=0005:0000256F:0000C63A\nHID_NAME=SpaceMouse Wireless BT\n";
|
||||
assert_eq!(ids(wireless), Some((0x256f, 0xc63a)));
|
||||
assert_eq!(ids("HID_ID=0003:0000046D\n"), None);
|
||||
assert_eq!(ids("HID_ID=0003:0001046D:0000C62B\n"), None, "a vendor past 16 bits isn't a USB id");
|
||||
assert_eq!(ids("HID_NAME=keyboard\n"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,3 +35,4 @@ sketch_scale = { master = "scale", accent = "sketch" }
|
||||
sketch_text = { master = "text", accent = "sketch" }
|
||||
surface_fair = { master = "spline_cv", accent = "surface" }
|
||||
mesh_export = { master = "export", accent = "mesh" }
|
||||
bom = "list"
|
||||
|
||||
@@ -218,6 +218,7 @@
|
||||
"sheet": "774e30923ef6f37dd9c0b2bc3e45705e83b3a76700bf071cd41155a365d0daa4",
|
||||
"sheet_rules": "b8c3a67c82be20fc00c109decc566e8755d70167e04e0a9fb92941f7b27aa993",
|
||||
"shell": "a1959ea0d0441a479bf654b6e549e2e44a0bfdc9cda4eb46fe1fbfd884de7e38",
|
||||
"silhouette": "2a848aed2f247b1c0439f830a34a513c93bc5493c40044c821d493d863ad996e",
|
||||
"sketch": "b52e5862b1709a9a7e3ca00d7436d126fbfc01b38080006fa4e17dc581dfa103",
|
||||
"sketch_break": "430582243311f304dbab397ce58c8c9aef9c78ed88d3e921a57e584ddb95d94c",
|
||||
"sketch_chamfer": "80ab27a9929f9cf0ab2f827757e007173d74ddca6c19af238931c40d4cd874b4",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Silhouette</title>
|
||||
<path d="M8.62 18.5L32 32L32 59L8.62 45.5Z" fill="#b8c3cf"/>
|
||||
<path d="M55.38 18.5L32 32L32 59L55.38 45.5Z" fill="#8e9faf"/>
|
||||
<path d="M32 5L55.38 18.5L32 32L8.62 18.5Z" fill="#e2e8ef"/>
|
||||
<path d="M8.62 18.5L32 32L55.38 18.5" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 32L32 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 5L55.38 18.5L55.38 45.5L32 59L8.62 45.5L8.62 18.5Z" fill="none" stroke="#ffffff" stroke-width="7.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<path d="M32 5L55.38 18.5L55.38 45.5L32 59L8.62 45.5L8.62 18.5Z" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 878 B |
@@ -113,7 +113,8 @@ def palette_css(palette):
|
||||
|
||||
|
||||
def revision(path):
|
||||
with open(path, 'rb') as source: return hashlib.sha256(source.read()).hexdigest()
|
||||
# As save_colours reads it: text, so a CRLF checkout's line endings read as LF.
|
||||
with open(path, encoding='utf-8') as source: return hashlib.sha256(source.read().encode()).hexdigest()
|
||||
|
||||
|
||||
def save_colours(path, base_revision, changes):
|
||||
|
||||
@@ -37,8 +37,11 @@ def art_rev():
|
||||
paths += glob.glob(HERE + '/*.py')
|
||||
paths += [D + '/palette.toml', D + '/aliases.toml', __file__]
|
||||
for path in sorted(paths):
|
||||
st = os.stat(path)
|
||||
h.update(('%s %d %d;' % (os.path.relpath(path, ROOT), st.st_mtime_ns, st.st_size)).encode())
|
||||
# Equal-length edits can share an mtime. Only the content identifies what
|
||||
# the reviewer saw; file metadata must never authorize stale acceptance.
|
||||
h.update(os.path.relpath(path, ROOT).replace(os.sep, '/').encode() + b'\0')
|
||||
with open(path, 'rb') as source:
|
||||
h.update(hashlib.sha256(review_state.lf(source.read())).digest())
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
|
||||
@@ -82,7 +85,7 @@ def scan():
|
||||
for f in glob.glob(ROOT + '/crates/ui-egui/src/**/*.rs', recursive=True):
|
||||
t = open(f, encoding='utf-8').read()
|
||||
for name in ui_icon_names(t):
|
||||
uses[name].append('ui:' + os.path.relpath(f, ROOT + '/crates/ui-egui/src'))
|
||||
uses[name].append('ui:' + os.path.relpath(f, ROOT + '/crates/ui-egui/src').replace(os.sep, '/'))
|
||||
mk = ROOT + '/assets/icons/ui/source/markers.rs'
|
||||
if os.path.exists(mk):
|
||||
for name in re.findall(r'=> "(c_[a-z_]+)"', open(mk, encoding='utf-8').read()): uses[name].append('ui:sketch constraint glyph')
|
||||
@@ -284,7 +287,7 @@ if '--serve' in args:
|
||||
request_id = uuid.uuid4().hex
|
||||
path = os.path.join(REQUESTS, request_id + '.json')
|
||||
# Publish a complete snapshot atomically so the chat never reads half a request.
|
||||
with open(path + '.tmp', 'w') as f:
|
||||
with open(path + '.tmp', 'w', encoding='utf-8') as f:
|
||||
json.dump({'version': 1, 'id': request_id,
|
||||
'created_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'notes': {i: r['notes'][i] for i in todo}}, f, indent=1)
|
||||
|
||||
@@ -80,16 +80,14 @@ if os.path.exists(references):
|
||||
for message in icon_palette.validation_errors(pal, names | set(al)):
|
||||
err(d + '/palette.toml', message)
|
||||
# an icon accepted as final on the review page must not change (un-accept it there to work on it again)
|
||||
import json, hashlib
|
||||
import json, review_state
|
||||
rp = d + '/review.json'
|
||||
if os.path.exists(rp):
|
||||
fin = json.load(open(rp)).get('final', {})
|
||||
fin = json.load(open(rp, encoding='utf-8')).get('final', {})
|
||||
for iid, want in (fin.items() if isinstance(fin, dict) else []):
|
||||
v = al.get(iid, iid); m = v.get('master') if isinstance(v, dict) else v
|
||||
if not want: continue
|
||||
h = hashlib.sha256()
|
||||
for f in sorted(glob.glob(d + '/svg/*/' + m + '.svg')):
|
||||
h.update(os.path.relpath(f, d).replace(os.sep, '/').encode()); h.update(open(f, 'rb').read())
|
||||
if h.hexdigest() != want: err(rp, 'final icon %s changed (master %s): un-accept it on the review page before editing it' % (iid, m))
|
||||
# The review page's own hash, so the two can't disagree.
|
||||
if review_state.master_hash(m, d) != want: err(rp, 'final icon %s changed (master %s): un-accept it on the review page before editing it' % (iid, m))
|
||||
print('\n'.join(bad) if bad else 'icons: %d masters, palette, naming and element rules ok' % len(files))
|
||||
sys.exit(1 if bad else 0)
|
||||
|
||||
@@ -14,9 +14,9 @@ for theme in ('light', 'dark'):
|
||||
for fam in sorted(os.listdir(d + '/svg')):
|
||||
h += '<div class="p %s"><h3>%s - %s</h3>' % (theme, fam, theme)
|
||||
for f in sorted(glob.glob('%s/svg/%s/*.svg' % (d, fam))):
|
||||
svg, icon = open(f).read(), os.path.basename(f)[:-4]
|
||||
svg, icon = open(f, encoding='utf-8').read(), os.path.basename(f)[:-4]
|
||||
h += '<div class=c>'
|
||||
h += '<div style="width:64px;height:64px;margin:auto">%s</div>' % themed(svg, theme, icon, fam, 64).replace('<svg ', '<svg width=64 height=64 ', 1)
|
||||
h += '<div class=s>' + ''.join('<span style="width:%dpx;height:%dpx;display:inline-block">%s</span>' % (s, s, themed(svg, theme, icon, fam, s).replace('<svg ', '<svg width=%d height=%d ' % (s, s), 1)) for s in (16, 20, 26)) + '</div>' + icon + '</div>'
|
||||
h += '</div>'
|
||||
open(out, 'w').write(h)
|
||||
open(out, 'w', encoding='utf-8').write(h)
|
||||
|
||||
@@ -16,10 +16,14 @@ def aliases():
|
||||
def master_of(icon_id, al=None):
|
||||
v = (aliases() if al is None else al).get(icon_id)
|
||||
return (v['master'] if isinstance(v, dict) else v) if v else icon_id
|
||||
def master_hash(master):
|
||||
def lf(data):
|
||||
"""Text as committed: a checkout with CRLF line endings (Git for Windows' default) hashes as LF."""
|
||||
return data.replace(b'\r\n', b'\n')
|
||||
def master_hash(master, design=None):
|
||||
d = D if design is None else design
|
||||
h = hashlib.sha256()
|
||||
for f in sorted(glob.glob(D + '/svg/*/' + master + '.svg')):
|
||||
h.update(os.path.relpath(f, D).replace(os.sep, '/').encode()); h.update(open(f, 'rb').read())
|
||||
for f in sorted(glob.glob(d + '/svg/*/' + master + '.svg')):
|
||||
h.update(os.path.relpath(f, d).replace(os.sep, '/').encode()); h.update(lf(open(f, 'rb').read()))
|
||||
return h.hexdigest()
|
||||
def load():
|
||||
try: r = json.load(open(REVIEW, encoding='utf-8'))
|
||||
|
||||
@@ -89,6 +89,15 @@ pub(crate) fn pin_icon(p: &egui::Painter, r: Rect, on: bool, col: Color32) {
|
||||
p.line_segment([at(0.0, -2.0), at(0.0, 7.0)], stroke);
|
||||
}
|
||||
|
||||
/// A breadcrumb separator ("Sketch › Create"): a thin chevron about 3 by 6 px, centred on `c`.
|
||||
/// Drawn rather than typed, because a font's "›" sits on the baseline in some languages
|
||||
/// (Japanese), where it reads as a comma.
|
||||
pub(crate) fn breadcrumb(p: &egui::Painter, c: egui::Pos2, col: Color32) {
|
||||
let s = Stroke::new(1.0, col);
|
||||
p.line_segment([pos2(c.x - 1.5, c.y - 3.0), pos2(c.x + 1.5, c.y)], s);
|
||||
p.line_segment([pos2(c.x + 1.5, c.y), pos2(c.x - 1.5, c.y + 3.0)], s);
|
||||
}
|
||||
|
||||
pub(crate) fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) {
|
||||
p.add(egui::Shape::convex_polygon(vec![pos2(c.x - 3.5, c.y - 2.0), pos2(c.x + 3.5, c.y - 2.0), pos2(c.x, c.y + 2.5)], col, Stroke::NONE));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Navigation drawing definitions. Camera projection and picking remain in the UI widgets.
|
||||
use crate::theme::Tokens;
|
||||
use egui::{Align2, Color32, FontId, Pos2, Rect, Shape, Stroke, pos2, vec2};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
use solidcraft_engine::geom::{Ring, Vec3};
|
||||
pub(crate) struct Facet {
|
||||
pub(crate) direction: Vec3,
|
||||
pub(crate) points: Vec<Pos2>,
|
||||
pub(crate) points: Ring<Pos2>,
|
||||
pub(crate) outer_edges: Vec<bool>,
|
||||
pub(crate) centre: Pos2,
|
||||
pub(crate) label: String,
|
||||
@@ -89,7 +89,7 @@ pub(crate) fn paint_cube(painter: &egui::Painter, faces: &[Facet], hovered: Opti
|
||||
for &p in &face.points {
|
||||
fill_mesh.colored_vertex(p, fill);
|
||||
}
|
||||
for (j, (&a, &b)) in face.points.iter().zip(face.points.iter().cycle().skip(1)).take(face.points.len()).enumerate() {
|
||||
for (j, (&a, &b)) in face.points.edges().enumerate() {
|
||||
fill_mesh.add_triangle(centre, centre + 1 + j as u32, centre + 1 + ((j + 1) % face.points.len()) as u32);
|
||||
// Coplanar picking regions have no visible seams. Only actual cube
|
||||
// edges and the hovered target's boundary receive an outline.
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Current orthographic view (1:1 mm)": "Aktuální pravoúhlý pohled (1:1 mm)",
|
||||
"Current perspective view": "Aktuální perspektivní pohled",
|
||||
"Curvature (G2)": "Křivost (G2)",
|
||||
"Curvature change (G3)": "Změna křivosti (G3)",
|
||||
"Customize Toolbar": "Přizpůsobit panel nástrojů",
|
||||
"Delete all": "Smazat vše",
|
||||
"Delete rule": "Smazat pravidlo",
|
||||
@@ -98,6 +99,7 @@
|
||||
"Export Current View as DXF…": "Exportovat aktuální pohled do DXF…",
|
||||
"Export DXF…": "Exportovat DXF…",
|
||||
"Export hidden edges as dashed lines": "Exportovat skryté hrany čárkovaně",
|
||||
"Extend to meet": "Prodloužit k protnutí",
|
||||
"Extents": "Rozsahy",
|
||||
"Face": "Plocha",
|
||||
"Face attachment lost": "Připojení k ploše ztraceno",
|
||||
@@ -112,6 +114,7 @@
|
||||
"Follow camera (Look At sketches)": "Sledovat kameru (náčrty natočené k pohledu)",
|
||||
"Follow the camera (Look At sketches)": "Sledovat kameru (náčrty natočené k pohledu)",
|
||||
"Font": "Písmo",
|
||||
"From edges": "Výchozí hrany",
|
||||
"Full Length": "Celá délka",
|
||||
"Grid": "Mřížka",
|
||||
"Height": "Výška",
|
||||
@@ -126,7 +129,9 @@
|
||||
"Interferes": "Koliduje",
|
||||
"Issue title": "Název hlášení",
|
||||
"Joint Origin1": "Počátek kloubu1",
|
||||
"Keep (points on it)": "Ponechat (body na ní)",
|
||||
"Keep Tools": "Ponechat nástroje",
|
||||
"Keep of the other surface": "Ponechat z druhé plochy",
|
||||
"Later": "Později",
|
||||
"Leave it": "Nechat být",
|
||||
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Starší typ připojení k ploše. Novým navázáním na ověřenou plochu vznikne přesné připojení.",
|
||||
@@ -162,6 +167,7 @@
|
||||
"Open GitHub draft": "Otevřít koncept na GitHubu",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Open-source parametrický 3D CAD v jazyce Rust. Licence MIT OR Apache-2.0.",
|
||||
"Other side": "Druhá strana",
|
||||
"Other surface (mutual trim)": "Druhá plocha (vzájemné oříznutí)",
|
||||
"Outside": "Vně",
|
||||
"Overall length": "Celková délka",
|
||||
"Parameter values": "Hodnoty parametrů",
|
||||
@@ -245,9 +251,13 @@
|
||||
"Stand this part up?": "Postavit tento díl?",
|
||||
"Step": "Krok",
|
||||
"Steps": "Kroky",
|
||||
"Stitch the kept pieces": "Sešít ponechané části",
|
||||
"Stitch to both bodies": "Sešít s oběma tělesy",
|
||||
"Style": "Styl",
|
||||
"Suppressed": "Potlačeno",
|
||||
"Surface": "Plocha",
|
||||
"Swap Ends": "Prohodit konce",
|
||||
"TRIM": "OŘÍZNOUT",
|
||||
"Tangent (G1)": "Tečně (G1)",
|
||||
"Tangent Chain": "Tečný řetězec",
|
||||
"Tear": "Roztržení",
|
||||
@@ -263,7 +273,9 @@
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "Tento potomek je sdílený. Změny polohy se projeví ve všech kopiích jeho rodiče.",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Tato plocha nemůže poskytnout trvalé připojení. Zvolte jinou plochu nebo výslovně použijte pevnou rovinu.",
|
||||
"To Offset": "Odsazení k objektu",
|
||||
"To edges": "Cílové hrany",
|
||||
"Tolerance": "Tolerance",
|
||||
"Trimming tool (plane, face or curves)": "Ořezávací nástroj (rovina, plocha nebo křivky)",
|
||||
"Turn off section analysis before exporting the view.": "Před exportem pohledu vypněte analýzu řezu.",
|
||||
"Type": "Typ",
|
||||
"UNTRIM": "ZRUŠIT OŘEZ",
|
||||
@@ -687,6 +699,15 @@
|
||||
"joint.from_bodies_hint": "Každé vybrané těleso se přesune do vlastní nové komponenty a zůstane na místě.",
|
||||
"joint.one_body": "Oba výběry jsou na {body}. Druhý vyberte na jiném dílu.",
|
||||
"joint.one_component": "Tyto díly jsou tělesy jedné komponenty: {bodies}. Kloub pohybuje jednou komponentou vůči druhé, takže každý díl potřebuje vlastní komponentu.",
|
||||
"messages.ago_h": "před {n} h",
|
||||
"messages.ago_min": "před {n} min",
|
||||
"messages.ago_s": "před {n} s",
|
||||
"messages.chip_hint": "Klikněte pro všechny zprávy této relace",
|
||||
"messages.clear": "Vymazat",
|
||||
"messages.copy_all": "Kopírovat vše",
|
||||
"messages.menu": "Zprávy…",
|
||||
"messages.none": "Zatím žádné zprávy.",
|
||||
"messages.title": "Zprávy",
|
||||
"nav.touchpad.help": "Podržením Shift a pohybem ukazatele posouváte; podržením Alt/Option a pohybem otáčíte. Tlačítko myši není potřeba. Svislé posouvání přibližuje.",
|
||||
"nav.touchpad.label": "Touchpad",
|
||||
"nav.touchpad.orbit": "Otáčení (podržte Alt/Option a pohybujte; bez tlačítka myši)",
|
||||
@@ -976,6 +997,7 @@
|
||||
"ui.autosave_every": "Automaticky ukládat každých",
|
||||
"ui.axes": "Osy",
|
||||
"ui.axis": "Osa",
|
||||
"ui.back": "Zpět",
|
||||
"ui.base_point": "Základní bod",
|
||||
"ui.blocked": "Blokováno",
|
||||
"ui.bodies": "Tělesa",
|
||||
@@ -1337,6 +1359,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Vyberte tělesa k uložení",
|
||||
"ui.save_mesh_temp_failed": "Nelze vytvořit {folder}: {error}",
|
||||
"ui.saved_to": "Uloženo {path}",
|
||||
"ui.sbox_pin": "Připnout nahoru ve vyhledávání S",
|
||||
"ui.sbox_unpin": "Odepnout",
|
||||
"ui.scale": "Měřítko",
|
||||
"ui.section_analysis": "Analýza řezu",
|
||||
"ui.select": "Vybrat",
|
||||
@@ -1379,8 +1403,34 @@
|
||||
"ui.smooth_wheel_zoom": "Plynulé přiblížení kolečkem",
|
||||
"ui.smooth_wheel_zoom_tip": "Vypnutím se každý pohyb kolečka projeví okamžitě, bez dobíhání",
|
||||
"ui.snap": "Přichytávání",
|
||||
"ui.space_mouse_calibrate": "Kalibrovat…",
|
||||
"ui.space_mouse_calibrate_tip": "Pohybujte hlavicí podle pokynů v okně, aby se SolidCraft naučil, kterým směrem a jak daleko se pohybuje každá osa této 3D myši. Uloží se pro tuto 3D myš.",
|
||||
"ui.space_mouse_calibrate_title": "Kalibrace 3D myši",
|
||||
"ui.space_mouse_calibrated": "Zkalibrováno",
|
||||
"ui.space_mouse_calibration": "Kalibrace 3D myši",
|
||||
"ui.space_mouse_calibration_done": "Hotovo. Uložte ji a použije se vždy, když bude tato 3D myš připojená.",
|
||||
"ui.space_mouse_calibration_no_response": "Jedna osa se skoro nepohnula, takže ji nelze zkalibrovat. Začněte znovu a pokaždé hlavicí pohněte dál.",
|
||||
"ui.space_mouse_dead_zone": "Mrtvá zóna",
|
||||
"ui.space_mouse_dead_zone_tip": "Jak daleko se musí hlavice pohnout, než se pohne pohled, jako podíl jejího plného rozsahu. Zvyšte ji, pokud pohled ujíždí, i když je hlavice v klidu.",
|
||||
"ui.space_mouse_forget": "Zapomenout kalibraci",
|
||||
"ui.space_mouse_hint": "Posun a otáčení pohledu současně; náčrty používají jen posun a přiblížení.",
|
||||
"ui.space_mouse_navigate": "Navigace pomocí SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "3D myš nenalezena",
|
||||
"ui.space_mouse_not_calibrated": "Nezkalibrováno",
|
||||
"ui.space_mouse_reading": "Hodnoty",
|
||||
"ui.space_mouse_step_lift": "Zvedněte hlavici nahoru, podržte a pusťte.",
|
||||
"ui.space_mouse_step_press": "Zatlačte hlavici dolů, podržte a pusťte.",
|
||||
"ui.space_mouse_step_pull": "Přitáhněte hlavici k sobě, podržte a pusťte.",
|
||||
"ui.space_mouse_step_push": "Odtlačte hlavici od sebe, podržte a pusťte.",
|
||||
"ui.space_mouse_step_rest": "Dejte ruku z hlavice a nechte ji v klidu.",
|
||||
"ui.space_mouse_step_slide_left": "Posuňte hlavici doleva, podržte a pusťte.",
|
||||
"ui.space_mouse_step_slide_right": "Posuňte hlavici doprava, podržte a pusťte.",
|
||||
"ui.space_mouse_step_tilt_back": "Nakloňte hlavici dozadu (přední hrana nahoru), podržte a pusťte.",
|
||||
"ui.space_mouse_step_tilt_forward": "Nakloňte hlavici dopředu (přední hrana dolů), podržte a pusťte.",
|
||||
"ui.space_mouse_step_tilt_left": "Nakloňte hlavici doleva (levá hrana dolů), podržte a pusťte.",
|
||||
"ui.space_mouse_step_tilt_right": "Nakloňte hlavici doprava (pravá hrana dolů), podržte a pusťte.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Otočte hlavici po směru hodinových ručiček, podržte a pusťte.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Otočte hlavici proti směru hodinových ručiček, podržte a pusťte.",
|
||||
"ui.spacing": "Rozteč",
|
||||
"ui.spacing_2": "Rozteč 2",
|
||||
"ui.sphere": "Koule",
|
||||
@@ -1388,6 +1438,7 @@
|
||||
"ui.split_body": "Rozdělit těleso",
|
||||
"ui.stand_up_tip": "Když se soubor STEP nebo IGES otevře s osou Z nahoru, nabídnout otočení o čtvrt otáčky kolem X",
|
||||
"ui.start": "Start",
|
||||
"ui.start_over": "Začít znovu",
|
||||
"ui.start_radius": "Počáteční poloměr",
|
||||
"ui.supersampling": "Supersampling (SSAA)",
|
||||
"ui.supersampling_adaptive": "Jen když je pohled v klidu",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"Current orthographic view (1:1 mm)": "Aktuelle orthogonale Ansicht (1:1 mm)",
|
||||
"Current perspective view": "Aktuelle perspektivische Ansicht",
|
||||
"Curvature (G2)": "Krümmung (G2)",
|
||||
"Curvature change (G3)": "Krümmungsänderung (G3)",
|
||||
"Direction": "Richtung",
|
||||
"Do not export hidden edges": "Verdeckte Kanten nicht exportieren",
|
||||
"Don't ask again": "Nicht mehr fragen",
|
||||
@@ -39,16 +40,20 @@
|
||||
"Export Current View as DXF…": "Aktuelle Ansicht als DXF exportieren…",
|
||||
"Export DXF…": "DXF exportieren…",
|
||||
"Export hidden edges as dashed lines": "Verdeckte Kanten gestrichelt exportieren",
|
||||
"Extend to meet": "Verlängern, bis sie sich treffen",
|
||||
"Face": "Fläche",
|
||||
"Faces": "Flächen",
|
||||
"Find in Browser": "Im Browser suchen",
|
||||
"Flat": "Flach",
|
||||
"From edges": "Ausgangskanten",
|
||||
"Hidden edges": "Verdeckte Kanten",
|
||||
"Holes": "Löcher",
|
||||
"Holes (edges on them)": "Löcher (ihre Kanten)",
|
||||
"In place (surfaces)": "An Ort und Stelle (Flächen)",
|
||||
"In plane": "In der Ebene",
|
||||
"Inside": "Innen",
|
||||
"Keep (points on it)": "Behalten (Punkte darauf)",
|
||||
"Keep of the other surface": "Vom anderen Flächenkörper behalten",
|
||||
"Length": "Länge",
|
||||
"Max": "Max",
|
||||
"Min": "Min",
|
||||
@@ -58,6 +63,7 @@
|
||||
"None": "Keine",
|
||||
"Normal to sketch": "Normal zur Skizze",
|
||||
"Notch": "Ausklinkung",
|
||||
"Other surface (mutual trim)": "Anderer Flächenkörper (gegenseitig trimmen)",
|
||||
"Outside": "Außen",
|
||||
"Overall length": "Gesamtlänge",
|
||||
"Pass through": "Durch Punkte",
|
||||
@@ -87,12 +93,18 @@
|
||||
"Sketch geometry and profiles": "Skizzengeometrie und Profile",
|
||||
"Sketch points": "Skizzenpunkte",
|
||||
"Slot width": "Langlochbreite",
|
||||
"Stitch the kept pieces": "Behaltene Teile vernähen",
|
||||
"Stitch to both bodies": "Mit beiden Körpern vernähen",
|
||||
"Study": "Untersuchung",
|
||||
"Surface": "Flächenkörper",
|
||||
"TRIM": "TRIMMEN",
|
||||
"Tangent (G1)": "Tangente (G1)",
|
||||
"Tear": "Reißen",
|
||||
"Thickness Outside": "Dicke außen",
|
||||
"Thin": "Dünnwandig",
|
||||
"Thin Extrude": "Dünnwandig extrudieren",
|
||||
"To edges": "Zielkanten",
|
||||
"Trimming tool (plane, face or curves)": "Trimmwerkzeug (Ebene, Fläche oder Kurven)",
|
||||
"Turn off section analysis before exporting the view.": "Deaktivieren Sie die Schnittanalyse, bevor Sie die Ansicht exportieren.",
|
||||
"Type": "Typ",
|
||||
"UNTRIM": "TRIMMEN AUFHEBEN",
|
||||
@@ -238,6 +250,7 @@
|
||||
"cmd.joint_rigid_group": "Starrgruppe",
|
||||
"cmd.joint_screw": "Schraube",
|
||||
"cmd.joint_solve": "Gelenke lösen",
|
||||
"cmd.joint_sweep": "Gelenk bis zum Kontakt durchfahren",
|
||||
"cmd.library_apply": "Lokalen Bibliothekseintrag anwenden",
|
||||
"cmd.library_export": "Designbibliothek exportieren",
|
||||
"cmd.library_inspect": "Lokale Bibliothek prüfen",
|
||||
@@ -425,8 +438,10 @@
|
||||
"cmd.timeline_roll_to": "Verlaufsmarkierung verschieben",
|
||||
"cmd.timeline_rollback": "Zeitleistenmarkierung verschieben",
|
||||
"cmd.timeline_suppress": "Merkmal unterdrücken",
|
||||
"cmd.view_ambient_occlusion": "Umgebungsverdeckung",
|
||||
"cmd.view_apply": "Benannte Ansicht wiederherstellen",
|
||||
"cmd.view_delete": "Benannte Ansicht löschen",
|
||||
"cmd.view_graphics_preset": "Grafikprofil",
|
||||
"cmd.view_list": "Benannte Ansichten auflisten",
|
||||
"cmd.view_navigate": "Ansicht navigieren",
|
||||
"cmd.view_rename": "Benannte Ansicht umbenennen",
|
||||
@@ -756,6 +771,8 @@
|
||||
"ui.ai_toolbar_button_note": "Die KI-Schaltfläche am rechten Ende der Werkzeugleiste. Strg+Umschalt+A und das Ansicht-Menü öffnen das Panel weiterhin.",
|
||||
"ui.ai_undo_tip": "Alles, was der Agent für diese Nachricht geändert hat, in einem Schritt rückgängig machen",
|
||||
"ui.all": "Alle",
|
||||
"ui.ambient_occlusion": "Umgebungsverdeckung",
|
||||
"ui.ambient_occlusion_help": "Sanfte Schattierung an Kontaktstellen und in Vertiefungen undurchsichtiger Flächen. Erfordert die GPU-Ansicht.",
|
||||
"ui.analysis": "Analyse",
|
||||
"ui.angle": "Winkel",
|
||||
"ui.angle_decimals": "Winkel-Dezimalstellen",
|
||||
@@ -770,6 +787,7 @@
|
||||
"ui.autosave_every": "Autospeichern alle",
|
||||
"ui.axes": "Achsen",
|
||||
"ui.axis": "Achse",
|
||||
"ui.back": "Zurück",
|
||||
"ui.base_point": "Basispunkt",
|
||||
"ui.blocked": "Verdeckt",
|
||||
"ui.bodies": "Körper",
|
||||
@@ -910,6 +928,11 @@
|
||||
"ui.fully_constrained": "Vollständig bestimmt",
|
||||
"ui.general": "Allgemein",
|
||||
"ui.graphics": "Grafik",
|
||||
"ui.graphics_custom": "Benutzerdefiniert",
|
||||
"ui.graphics_performance": "Leistung",
|
||||
"ui.graphics_preset": "Grafikprofil",
|
||||
"ui.graphics_preset_help": "Qualität: Umgebungsverdeckung, feine Anzeigequalität und adaptives 2× Supersampling. Leistung: keine Umgebungsverdeckung, normale Anzeigequalität und kein Supersampling. Manuelle Änderungen: Benutzerdefiniert. MSAA bleibt unverändert.",
|
||||
"ui.graphics_quality": "Qualität",
|
||||
"ui.groupSelected": "Auswahl gruppieren",
|
||||
"ui.heading_assemble": "ZUSAMMENBAU",
|
||||
"ui.heading_box": "BLOCK",
|
||||
@@ -1118,6 +1141,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Wähle die Körper zum Speichern aus",
|
||||
"ui.save_mesh_temp_failed": "{folder} konnte nicht angelegt werden: {error}",
|
||||
"ui.saved_to": "Gespeichert: {path}",
|
||||
"ui.sbox_pin": "Oben im S-Feld anheften",
|
||||
"ui.sbox_unpin": "Lösen",
|
||||
"ui.scale": "Skalieren",
|
||||
"ui.second_rail": "Zweite Leitkurve",
|
||||
"ui.section_analysis": "Querschnittsanalyse",
|
||||
@@ -1158,12 +1183,39 @@
|
||||
"ui.slot": "Langloch",
|
||||
"ui.smooth": "Glatt",
|
||||
"ui.snap": "Einrasten",
|
||||
"ui.space_mouse_calibrate": "Kalibrieren…",
|
||||
"ui.space_mouse_calibrate_tip": "Bewegen Sie die Kappe so, wie das Fenster es verlangt. So lernt SolidCraft, in welche Richtung und wie weit jede Achse dieser 3D-Maus geht. Wird für diese 3D-Maus gespeichert.",
|
||||
"ui.space_mouse_calibrate_title": "3D-Maus kalibrieren",
|
||||
"ui.space_mouse_calibrated": "Kalibriert",
|
||||
"ui.space_mouse_calibration": "3D-Maus-Kalibrierung",
|
||||
"ui.space_mouse_calibration_done": "Fertig. Speichern Sie die Kalibrierung, damit sie immer gilt, wenn diese 3D-Maus angeschlossen ist.",
|
||||
"ui.space_mouse_calibration_no_response": "Eine Achse hat sich kaum bewegt und lässt sich nicht kalibrieren. Beginnen Sie von vorn und bewegen Sie die Kappe jedes Mal weiter.",
|
||||
"ui.space_mouse_dead_zone": "Totzone",
|
||||
"ui.space_mouse_dead_zone_tip": "Wie weit sich die Kappe bewegen muss, bevor sich die Ansicht bewegt, als Anteil ihres vollen Wegs. Erhöhen Sie sie, wenn die Ansicht wandert, obwohl die Kappe ruht.",
|
||||
"ui.space_mouse_forget": "Kalibrierung vergessen",
|
||||
"ui.space_mouse_none": "Keine 3D-Maus gefunden",
|
||||
"ui.space_mouse_not_calibrated": "Nicht kalibriert",
|
||||
"ui.space_mouse_reading": "Werte",
|
||||
"ui.space_mouse_step_lift": "Kappe nach oben ziehen, halten, dann loslassen.",
|
||||
"ui.space_mouse_step_press": "Kappe nach unten drücken, halten, dann loslassen.",
|
||||
"ui.space_mouse_step_pull": "Kappe zu sich heranziehen, halten, dann loslassen.",
|
||||
"ui.space_mouse_step_push": "Kappe von sich wegschieben, halten, dann loslassen.",
|
||||
"ui.space_mouse_step_rest": "Nehmen Sie die Hand von der Kappe und lassen Sie sie ruhen.",
|
||||
"ui.space_mouse_step_slide_left": "Kappe nach links schieben, halten, dann loslassen.",
|
||||
"ui.space_mouse_step_slide_right": "Kappe nach rechts schieben, halten, dann loslassen.",
|
||||
"ui.space_mouse_step_tilt_back": "Kappe nach hinten kippen (Vorderkante hoch), halten, dann loslassen.",
|
||||
"ui.space_mouse_step_tilt_forward": "Kappe nach vorn kippen (Vorderkante runter), halten, dann loslassen.",
|
||||
"ui.space_mouse_step_tilt_left": "Kappe nach links kippen (linke Kante runter), halten, dann loslassen.",
|
||||
"ui.space_mouse_step_tilt_right": "Kappe nach rechts kippen (rechte Kante runter), halten, dann loslassen.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Kappe im Uhrzeigersinn drehen, halten, dann loslassen.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Kappe gegen den Uhrzeigersinn drehen, halten, dann loslassen.",
|
||||
"ui.spacing": "Abstand",
|
||||
"ui.spacing_2": "Abstand 2",
|
||||
"ui.sphere": "Sphäre",
|
||||
"ui.spline": "Spline",
|
||||
"ui.split_body": "Körper trennen",
|
||||
"ui.start": "Start",
|
||||
"ui.start_over": "Von vorn beginnen",
|
||||
"ui.start_radius": "Startradius",
|
||||
"ui.stretch": "Strecken",
|
||||
"ui.supersampling": "Supersampling (SSAA)",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"Arc length": "Arc length",
|
||||
"Area": "Area",
|
||||
"Attachment": "Attachment",
|
||||
"BLEND SURFACE": "BLEND SURFACE",
|
||||
"BOUNDARY SURFACE": "BOUNDARY SURFACE",
|
||||
"BROWSER": "BROWSER",
|
||||
"Bend Line Position": "Bend Line Position",
|
||||
@@ -79,6 +80,7 @@
|
||||
"Current orthographic view (1:1 mm)": "Current orthographic view (1:1 mm)",
|
||||
"Current perspective view": "Current perspective view",
|
||||
"Curvature (G2)": "Curvature (G2)",
|
||||
"Curvature change (G3)": "Curvature change (G3)",
|
||||
"Customize Toolbar": "Customize Toolbar",
|
||||
"Delete all": "Delete all",
|
||||
"Delete rule": "Delete rule",
|
||||
@@ -100,6 +102,8 @@
|
||||
"Edit Profile Sketch": "Edit Profile Sketch",
|
||||
"Enable rule": "Enable rule",
|
||||
"Enabled": "Enabled",
|
||||
"End bulge": "End bulge",
|
||||
"End continuity": "End continuity",
|
||||
"Explode": "Explode",
|
||||
"Explode Text": "Explode Text",
|
||||
"Export Animation…": "Export Animation…",
|
||||
@@ -107,6 +111,7 @@
|
||||
"Export Current View as DXF…": "Export Current View as DXF…",
|
||||
"Export DXF…": "Export DXF…",
|
||||
"Export hidden edges as dashed lines": "Export hidden edges as dashed lines",
|
||||
"Extend to meet": "Extend to meet",
|
||||
"Extents": "Extents",
|
||||
"Face": "Face",
|
||||
"Face attachment lost": "Face attachment lost",
|
||||
@@ -121,6 +126,7 @@
|
||||
"Follow camera (Look At sketches)": "Follow camera (Look At sketches)",
|
||||
"Follow the camera (Look At sketches)": "Follow the camera (Look At sketches)",
|
||||
"Font": "Font",
|
||||
"From edges": "From edges",
|
||||
"Full Length": "Full Length",
|
||||
"Grid": "Grid",
|
||||
"Height": "Height",
|
||||
@@ -135,7 +141,9 @@
|
||||
"Interferes": "Interferes",
|
||||
"Issue title": "Issue title",
|
||||
"Joint Origin1": "Joint Origin1",
|
||||
"Keep (points on it)": "Keep (points on it)",
|
||||
"Keep Tools": "Keep Tools",
|
||||
"Keep of the other surface": "Keep of the other surface",
|
||||
"Later": "Later",
|
||||
"Leave it": "Leave it",
|
||||
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Legacy face attachment. Rebinding to a certified face creates an exact attachment.",
|
||||
@@ -173,6 +181,7 @@
|
||||
"Open GitHub draft": "Open GitHub draft",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.",
|
||||
"Other side": "Other side",
|
||||
"Other surface (mutual trim)": "Other surface (mutual trim)",
|
||||
"Outside": "Outside",
|
||||
"Overall length": "Overall length",
|
||||
"Parameter values": "Parameter values",
|
||||
@@ -258,12 +267,18 @@
|
||||
"Stand it up": "Stand it up",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Stand it up: a quarter turn about X, as one Move feature you can undo.",
|
||||
"Stand this part up?": "Stand this part up?",
|
||||
"Start bulge": "Start bulge",
|
||||
"Start continuity": "Start continuity",
|
||||
"Step": "Step",
|
||||
"Steps": "Steps",
|
||||
"Stitch the kept pieces": "Stitch the kept pieces",
|
||||
"Stitch to both bodies": "Stitch to both bodies",
|
||||
"Study": "Study",
|
||||
"Style": "Style",
|
||||
"Suppressed": "Suppressed",
|
||||
"Surface": "Surface",
|
||||
"Swap Ends": "Swap Ends",
|
||||
"TRIM": "TRIM",
|
||||
"Tangent (G1)": "Tangent (G1)",
|
||||
"Tangent Chain": "Tangent Chain",
|
||||
"Tear": "Tear",
|
||||
@@ -279,8 +294,10 @@
|
||||
"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.",
|
||||
"To Offset": "To Offset",
|
||||
"To edges": "To edges",
|
||||
"Tolerance": "Tolerance",
|
||||
"Too close": "Too close",
|
||||
"Trimming tool (plane, face or curves)": "Trimming tool (plane, face or curves)",
|
||||
"Turn off section analysis before exporting the view.": "Turn off section analysis before exporting the view.",
|
||||
"Type": "Type",
|
||||
"UNTRIM": "UNTRIM",
|
||||
@@ -325,6 +342,7 @@
|
||||
"cmd.appearance_assign": "Appearance",
|
||||
"cmd.appearance_library": "Appearance Library",
|
||||
"cmd.appearance_list": "List Appearances",
|
||||
"cmd.assembly_bom": "Bill of Materials",
|
||||
"cmd.body_rename": "Rename Body",
|
||||
"cmd.browser_group": "New Group",
|
||||
"cmd.browser_move": "Move Browser Items",
|
||||
@@ -346,10 +364,13 @@
|
||||
"cmd.component_insert_design": "Insert Design",
|
||||
"cmd.component_links": "List Links",
|
||||
"cmd.component_list": "List Components",
|
||||
"cmd.component_metadata": "Part Properties",
|
||||
"cmd.component_metadata_set": "Change Part Properties",
|
||||
"cmd.component_move_bodies": "Move Bodies to Component",
|
||||
"cmd.component_move_sketches": "Move Sketches to Component",
|
||||
"cmd.component_paste_new": "Paste New",
|
||||
"cmd.component_properties": "Component Physical Properties",
|
||||
"cmd.component_relink": "Relink Source",
|
||||
"cmd.component_rename": "Rename Component",
|
||||
"cmd.component_revert_position": "Revert Position",
|
||||
"cmd.component_unlock": "Unlock Contents",
|
||||
@@ -401,6 +422,7 @@
|
||||
"cmd.edit_checkpoint": "Checkpoint",
|
||||
"cmd.edit_restore_checkpoint": "Restore Checkpoint",
|
||||
"cmd.engine_commands": "List Commands",
|
||||
"cmd.engine_describe": "Describe Command",
|
||||
"cmd.explode_create": "Exploded View",
|
||||
"cmd.explode_delete": "Delete Exploded View",
|
||||
"cmd.explode_list": "List Exploded Views",
|
||||
@@ -433,6 +455,7 @@
|
||||
"cmd.joint_drive": "Drive Joints",
|
||||
"cmd.joint_edit": "Edit Joint",
|
||||
"cmd.joint_gear": "Gear",
|
||||
"cmd.joint_inspect": "Inspect Joint",
|
||||
"cmd.joint_limits": "Joint Limits",
|
||||
"cmd.joint_list": "List Joints",
|
||||
"cmd.joint_motion_link": "Motion Link",
|
||||
@@ -442,6 +465,7 @@
|
||||
"cmd.joint_rigid_group": "Rigid Group",
|
||||
"cmd.joint_screw": "Screw",
|
||||
"cmd.joint_solve": "Solve Joints",
|
||||
"cmd.joint_sweep": "Sweep Joint",
|
||||
"cmd.library_apply": "Apply Local Library Entry",
|
||||
"cmd.library_export": "Export Design Library",
|
||||
"cmd.library_inspect": "Inspect Local Library",
|
||||
@@ -590,6 +614,7 @@
|
||||
"cmd.sketch_spline_fit_point": "Fit Point Spline",
|
||||
"cmd.sketch_spun_profile": "Spun Profile",
|
||||
"cmd.sketch_toggle_driven": "Toggle Driven Dimension",
|
||||
"cmd.sketch_trim_path": "Trim Along Path",
|
||||
"cmd.solid_align": "Align",
|
||||
"cmd.solid_boundary_fill": "Boundary Fill",
|
||||
"cmd.solid_bounding_solid": "Bounding Solid",
|
||||
@@ -632,8 +657,10 @@
|
||||
"cmd.timeline_roll_to": "Roll History Marker",
|
||||
"cmd.timeline_rollback": "Move Timeline Marker",
|
||||
"cmd.timeline_suppress": "Suppress Feature",
|
||||
"cmd.view_ambient_occlusion": "Ambient Occlusion",
|
||||
"cmd.view_apply": "Restore Named View",
|
||||
"cmd.view_delete": "Delete Named View",
|
||||
"cmd.view_graphics_preset": "Graphics Preset",
|
||||
"cmd.view_list": "List Named Views",
|
||||
"cmd.view_navigate": "Navigate View",
|
||||
"cmd.view_rename": "Rename Named View",
|
||||
@@ -716,6 +743,15 @@
|
||||
"joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is.",
|
||||
"joint.one_body": "Both picks are on {body}. Pick the second one on another part.",
|
||||
"joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.",
|
||||
"messages.ago_h": "{n} h ago",
|
||||
"messages.ago_min": "{n} min ago",
|
||||
"messages.ago_s": "{n} s ago",
|
||||
"messages.chip_hint": "Click for all messages of this session",
|
||||
"messages.clear": "Clear",
|
||||
"messages.copy_all": "Copy all",
|
||||
"messages.menu": "Messages…",
|
||||
"messages.none": "No messages yet.",
|
||||
"messages.title": "Messages",
|
||||
"motion.checked_to": "Checked to step {step} only: the check ran out of its work budget",
|
||||
"motion.collision": "{a} · {b}: steps {steps}",
|
||||
"motion.duration_hint": "seconds (empty: per step)",
|
||||
@@ -857,7 +893,7 @@
|
||||
"ui.agent_chat_approval": "Allow the agent to do this?",
|
||||
"ui.agent_chat_attach_view": "Attach view",
|
||||
"ui.agent_chat_attach_view_tip": "Send a picture of the current view with the message",
|
||||
"ui.agent_chat_attachable": "Pictures, text and CAD files",
|
||||
"ui.agent_chat_attachable": "Pictures, PDFs, text and CAD files",
|
||||
"ui.agent_chat_auth_agent": "Sign in with {name}",
|
||||
"ui.agent_chat_auth_env": "Set {vars} in your environment and restart SolidCraft.",
|
||||
"ui.agent_chat_auth_needed": "The agent asks you to sign in. You sign in with the agent itself; SolidCraft never sees your password or keys.",
|
||||
@@ -889,7 +925,6 @@
|
||||
"ui.agent_chat_file_error": "{name}: {error}",
|
||||
"ui.agent_chat_file_heic": "{name}: HEIC pictures can't be read here; export it as JPEG or PNG first",
|
||||
"ui.agent_chat_file_importing": "Importing into the design…",
|
||||
"ui.agent_chat_file_kinds": "{name}: pictures (PNG, JPEG, GIF, WebP, BMP, TIFF), text files (SVG too) and CAD files (STEP, IGES, FreeCAD, Rhino, STL, 3MF, OBJ, glTF) can be attached",
|
||||
"ui.agent_chat_file_loading": "Reading the file…",
|
||||
"ui.agent_chat_file_not_a_file": "{name}: not a file",
|
||||
"ui.agent_chat_file_not_a_picture": "{name}: not a picture",
|
||||
@@ -1036,6 +1071,8 @@
|
||||
"ui.ai_toolbar_button_note": "The AI button at the right end of the toolbar. Ctrl+Shift+A and the View menu still open the panel.",
|
||||
"ui.ai_undo_tip": "Undo everything the agent changed for this message, as one step",
|
||||
"ui.all": "All",
|
||||
"ui.ambient_occlusion": "Ambient occlusion",
|
||||
"ui.ambient_occlusion_help": "Soft shading where opaque surfaces meet. Requires the GPU viewport.",
|
||||
"ui.analysis": "Analysis",
|
||||
"ui.angle": "Angle",
|
||||
"ui.angle_decimals": "Angle decimals",
|
||||
@@ -1050,6 +1087,7 @@
|
||||
"ui.autosave_every": "Autosave every",
|
||||
"ui.axes": "Axes",
|
||||
"ui.axis": "Axis",
|
||||
"ui.back": "Back",
|
||||
"ui.base_point": "Base point",
|
||||
"ui.blocked": "Blocked",
|
||||
"ui.bodies": "Bodies",
|
||||
@@ -1196,6 +1234,11 @@
|
||||
"ui.fully_constrained": "Fully constrained",
|
||||
"ui.general": "General",
|
||||
"ui.graphics": "Graphics",
|
||||
"ui.graphics_custom": "Custom",
|
||||
"ui.graphics_performance": "Performance",
|
||||
"ui.graphics_preset": "Graphics Preset",
|
||||
"ui.graphics_preset_help": "Quality: ambient occlusion, Fine display quality and adaptive 2× supersampling. Performance: no ambient occlusion, Normal display quality and supersampling off. Manual changes show Custom. MSAA stays unchanged.",
|
||||
"ui.graphics_quality": "Quality",
|
||||
"ui.grid_follows_view": "Grid follows the view",
|
||||
"ui.grid_follows_view_hint": "Side views show the grid on the upright plane facing them instead of the ground",
|
||||
"ui.ground_shadow": "Ground shadow",
|
||||
@@ -1298,6 +1341,8 @@
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Look At",
|
||||
"ui.look_at_anim_desc": "Axis view, Look At, Home and Fit",
|
||||
"ui.manipulator_free_alt": "Manipulator free drag also on Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "On: Alt/Option or Ctrl/Cmd on extrude / gizmo arrows disables value snapping. Off: Ctrl/Cmd only.",
|
||||
"ui.manufacture": "Manufacture",
|
||||
"ui.material_review.axes": "Material-local axes; no body orientation is assigned.",
|
||||
"ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.",
|
||||
@@ -1337,6 +1382,7 @@
|
||||
"ui.newSketchGroup": "New Sketch Group",
|
||||
"ui.new_body": "New Body",
|
||||
"ui.new_design": "New Design",
|
||||
"ui.no_geometry_yet": "No geometry yet",
|
||||
"ui.no_slicer_found": "No slicer found",
|
||||
"ui.normal": "Normal",
|
||||
"ui.normalConstruction": "Normal / Construction",
|
||||
@@ -1364,6 +1410,7 @@
|
||||
"ui.pan_zoom_orbit": "Pan, zoom, orbit",
|
||||
"ui.parallel": "Parallel",
|
||||
"ui.parameters": "Parameters",
|
||||
"ui.part_size_shared": "Rebuilds the part, replacing any added features. All occurrences of this component update.",
|
||||
"ui.paste": "Paste",
|
||||
"ui.path": "Path",
|
||||
"ui.pattern_on_path": "Pattern on Path",
|
||||
@@ -1375,6 +1422,12 @@
|
||||
"ui.placement": "Placement",
|
||||
"ui.plane": "Plane",
|
||||
"ui.plane_at_angle": "Plane at Angle",
|
||||
"ui.plugins": "Plugins",
|
||||
"ui.plugins_desktop": "Plugins are available in the desktop app.",
|
||||
"ui.plugins_empty": "No plugins installed.",
|
||||
"ui.plugins_note": "Each plugin is a folder inside {path} with a plugin.json. SolidCraft reads that file and does not run any code from the folder. Turning a plugin off keeps it installed.",
|
||||
"ui.plugins_skipped": "{count} folder(s) could not be read.",
|
||||
"ui.plugins_version": "{version}",
|
||||
"ui.point": "Point",
|
||||
"ui.point_under_the_cursor": "Point under the cursor",
|
||||
"ui.points": "Points",
|
||||
@@ -1400,6 +1453,11 @@
|
||||
"ui.rectangle": "Rectangle",
|
||||
"ui.rectangular_pattern": "Rectangular Pattern",
|
||||
"ui.redo": "Redo",
|
||||
"ui.relink_design_changed": "The assembly changed while choosing a source. Start Relink Source again.",
|
||||
"ui.relink_done": "Source relinked for all instances.",
|
||||
"ui.relink_not_linked": "This component is not linked to a source design.",
|
||||
"ui.relink_select_one": "Select one linked component or one of its instances first.",
|
||||
"ui.relink_source_all": "Relink Source (All Instances)…",
|
||||
"ui.removeFromToolbar": "Remove from Toolbar",
|
||||
"ui.removeSection": "Remove Section",
|
||||
"ui.render": "Render",
|
||||
@@ -1423,6 +1481,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Select the bodies to save",
|
||||
"ui.save_mesh_temp_failed": "Couldn't make {folder}: {error}",
|
||||
"ui.saved_to": "Saved {path}",
|
||||
"ui.sbox_pin": "Pin to the top of the S box",
|
||||
"ui.sbox_unpin": "Unpin",
|
||||
"ui.scale": "Scale",
|
||||
"ui.second_rail": "Second Rail",
|
||||
"ui.section_analysis": "Section Analysis",
|
||||
@@ -1431,6 +1491,9 @@
|
||||
"ui.selectTangentChain": "Select Tangent Chain",
|
||||
"ui.select_other_hint": "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.",
|
||||
"ui.selection": "Selection",
|
||||
"ui.selection_modifiers": "Selection modifiers",
|
||||
"ui.selection_shift_add_only": "Shift+click adds without removing",
|
||||
"ui.selection_shift_add_only_tip": "On: Shift adds items, Ctrl/Cmd toggles. Off (default): Shift and Ctrl/Cmd both toggle.",
|
||||
"ui.send": "Send",
|
||||
"ui.send_to_slicer": "Send to slicer",
|
||||
"ui.sent_to_slicer": "Sent to {slicer}",
|
||||
@@ -1455,6 +1518,7 @@
|
||||
"ui.sketch": "Sketch",
|
||||
"ui.sketch_dimension": "Sketch Dimension",
|
||||
"ui.sketch_grid": "Sketch Grid",
|
||||
"ui.sketch_modifiers": "Sketch modifiers",
|
||||
"ui.sketch_scale": "Sketch Scale",
|
||||
"ui.sketch_status": "Sketch Status",
|
||||
"ui.sketches": "Sketches",
|
||||
@@ -1466,8 +1530,34 @@
|
||||
"ui.smooth_wheel_zoom": "Smooth wheel zoom",
|
||||
"ui.smooth_wheel_zoom_tip": "Turn off to apply each wheel event immediately, without a smoothing tail",
|
||||
"ui.snap": "Snap",
|
||||
"ui.space_mouse_calibrate": "Calibrate…",
|
||||
"ui.space_mouse_calibrate_tip": "Move the cap as the window asks, so SolidCraft learns which way each axis of this 3D mouse goes and how far. Saved for this 3D mouse.",
|
||||
"ui.space_mouse_calibrate_title": "Calibrate 3D mouse",
|
||||
"ui.space_mouse_calibrated": "Calibrated",
|
||||
"ui.space_mouse_calibration": "3D mouse calibration",
|
||||
"ui.space_mouse_calibration_done": "Done. Save it to use it whenever this 3D mouse is connected.",
|
||||
"ui.space_mouse_calibration_no_response": "One axis barely moved, so it can't be calibrated. Start over and move the cap further each time.",
|
||||
"ui.space_mouse_dead_zone": "Dead zone",
|
||||
"ui.space_mouse_dead_zone_tip": "How far the cap has to move before the view does, as a share of its full travel. Raise it if the view drifts while the cap is at rest.",
|
||||
"ui.space_mouse_forget": "Forget calibration",
|
||||
"ui.space_mouse_hint": "Move and rotate the view together; sketches use pan and zoom only.",
|
||||
"ui.space_mouse_navigate": "Navigate with SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "No 3D mouse found",
|
||||
"ui.space_mouse_not_calibrated": "Not calibrated",
|
||||
"ui.space_mouse_reading": "Reading",
|
||||
"ui.space_mouse_step_lift": "Lift the cap up, hold it, then let go.",
|
||||
"ui.space_mouse_step_press": "Press the cap down, hold it, then let go.",
|
||||
"ui.space_mouse_step_pull": "Pull the cap toward you, hold it, then let go.",
|
||||
"ui.space_mouse_step_push": "Push the cap away from you, hold it, then let go.",
|
||||
"ui.space_mouse_step_rest": "Take your hand off the cap and leave it still.",
|
||||
"ui.space_mouse_step_slide_left": "Slide the cap left, hold it, then let go.",
|
||||
"ui.space_mouse_step_slide_right": "Slide the cap right, hold it, then let go.",
|
||||
"ui.space_mouse_step_tilt_back": "Tilt the cap back (front edge up), hold it, then let go.",
|
||||
"ui.space_mouse_step_tilt_forward": "Tilt the cap forward (front edge down), hold it, then let go.",
|
||||
"ui.space_mouse_step_tilt_left": "Tilt the cap left (left edge down), hold it, then let go.",
|
||||
"ui.space_mouse_step_tilt_right": "Tilt the cap right (right edge down), hold it, then let go.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Twist the cap clockwise, hold it, then let go.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Twist the cap anticlockwise, hold it, then let go.",
|
||||
"ui.spacing": "Spacing",
|
||||
"ui.spacing_2": "Spacing 2",
|
||||
"ui.sphere": "Sphere",
|
||||
@@ -1475,12 +1565,15 @@
|
||||
"ui.split_body": "Split Body",
|
||||
"ui.stand_up_tip": "After a STEP or IGES file opens with Z up, offer a quarter turn about X",
|
||||
"ui.start": "Start",
|
||||
"ui.start_over": "Start over",
|
||||
"ui.start_radius": "Start Radius",
|
||||
"ui.stretch": "Stretch",
|
||||
"ui.supersampling": "Supersampling (SSAA)",
|
||||
"ui.supersampling_adaptive": "Only when the view is still",
|
||||
"ui.supersampling_off": "Off (1×)",
|
||||
"ui.suppress": "Suppress",
|
||||
"ui.suppress_auto_snap": "Suppress auto-snap while drawing / dragging",
|
||||
"ui.suppress_auto_snap_note": "Default: Ctrl/Cmd or Alt/Option each suppress sketch snapping. Choose a single key to narrow it.",
|
||||
"ui.sweep": "Sweep",
|
||||
"ui.sweep_follow_direction": "Follow Direction",
|
||||
"ui.sweep_keep_as_drawn": "Keep as Drawn",
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"Current orthographic view (1:1 mm)": "Vista ortográfica actual (1:1 mm)",
|
||||
"Current perspective view": "Vista en perspectiva actual",
|
||||
"Curvature (G2)": "Curvatura (G2)",
|
||||
"Curvature change (G3)": "Cambio de curvatura (G3)",
|
||||
"Customize Toolbar": "Personalizar barra de herramientas",
|
||||
"Delete all": "Eliminar todo",
|
||||
"Delete rule": "Eliminar regla",
|
||||
@@ -105,6 +106,7 @@
|
||||
"Export Current View as DXF…": "Exportar vista actual como DXF…",
|
||||
"Export DXF…": "Exportar DXF…",
|
||||
"Export hidden edges as dashed lines": "Exportar aristas ocultas como líneas discontinuas",
|
||||
"Extend to meet": "Extender hasta encontrarla",
|
||||
"Extents": "Extensión",
|
||||
"Face": "Cara",
|
||||
"Face attachment lost": "Se ha perdido la vinculación de la cara",
|
||||
@@ -119,6 +121,7 @@
|
||||
"Follow camera (Look At sketches)": "Seguir cámara (orientar hacia los bocetos)",
|
||||
"Follow the camera (Look At sketches)": "Seguir la cámara (orientar hacia los bocetos)",
|
||||
"Font": "Fuente",
|
||||
"From edges": "Aristas de origen",
|
||||
"Full Length": "Longitud total",
|
||||
"Grid": "Cuadrícula",
|
||||
"Height": "Altura",
|
||||
@@ -133,7 +136,9 @@
|
||||
"Interferes": "Interfiere",
|
||||
"Issue title": "Título de la incidencia",
|
||||
"Joint Origin1": "Origen de unión1",
|
||||
"Keep (points on it)": "Conservar (puntos sobre ella)",
|
||||
"Keep Tools": "Conservar herramientas",
|
||||
"Keep of the other surface": "Conservar de la otra superficie",
|
||||
"Later": "Más tarde",
|
||||
"Leave it": "Dejar como está",
|
||||
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Vinculación antigua de cara. Volver a vincularla a una cara verificada crea una vinculación exacta.",
|
||||
@@ -171,6 +176,7 @@
|
||||
"Open GitHub draft": "Abrir borrador de GitHub",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAD 3D paramétrico de código abierto desarrollado en Rust. Licencia MIT O Apache-2.0.",
|
||||
"Other side": "Lado opuesto",
|
||||
"Other surface (mutual trim)": "Otra superficie (recorte mutuo)",
|
||||
"Outside": "Exterior",
|
||||
"Overall length": "Longitud total",
|
||||
"Parameter values": "Valores de parámetros",
|
||||
@@ -258,10 +264,14 @@
|
||||
"Stand this part up?": "¿Poner esta pieza en vertical?",
|
||||
"Step": "Paso",
|
||||
"Steps": "Pasos",
|
||||
"Stitch the kept pieces": "Coser las piezas conservadas",
|
||||
"Stitch to both bodies": "Coser a ambos cuerpos",
|
||||
"Study": "Estudio",
|
||||
"Style": "Estilo",
|
||||
"Suppressed": "Suprimido",
|
||||
"Surface": "Superficie",
|
||||
"Swap Ends": "Intercambiar extremos",
|
||||
"TRIM": "RECORTAR",
|
||||
"Tangent (G1)": "Tangencia (G1)",
|
||||
"Tangent Chain": "Cadena tangente",
|
||||
"Tear": "Desgarro",
|
||||
@@ -277,8 +287,10 @@
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "Este elemento secundario es compartido. Los cambios de posición se aplican a todas las copias de su elemento principal.",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara no permite una vinculación persistente. Elige otra cara o utiliza expresamente un plano fijo.",
|
||||
"To Offset": "Hasta desfase",
|
||||
"To edges": "Aristas de destino",
|
||||
"Tolerance": "Tolerancia",
|
||||
"Too close": "Demasiado cerca",
|
||||
"Trimming tool (plane, face or curves)": "Herramienta de recorte (plano, cara o curvas)",
|
||||
"Turn off section analysis before exporting the view.": "Desactiva el análisis de sección antes de exportar la vista.",
|
||||
"Type": "Tipo",
|
||||
"UNTRIM": "ANULAR RECORTE",
|
||||
@@ -1004,6 +1016,7 @@
|
||||
"ui.autosave_every": "Guardar automáticamente cada",
|
||||
"ui.axes": "Ejes",
|
||||
"ui.axis": "Eje",
|
||||
"ui.back": "Atrás",
|
||||
"ui.base_point": "Punto base",
|
||||
"ui.blocked": "Bloqueado",
|
||||
"ui.bodies": "Cuerpos",
|
||||
@@ -1372,6 +1385,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Selecciona los cuerpos que se van a guardar",
|
||||
"ui.save_mesh_temp_failed": "No se ha podido crear {folder}: {error}",
|
||||
"ui.saved_to": "Guardado en {path}",
|
||||
"ui.sbox_pin": "Fijar arriba en la búsqueda S",
|
||||
"ui.sbox_unpin": "Desfijar",
|
||||
"ui.scale": "Escala",
|
||||
"ui.second_rail": "Segunda línea guía",
|
||||
"ui.section_analysis": "Análisis de sección",
|
||||
@@ -1415,8 +1430,34 @@
|
||||
"ui.smooth_wheel_zoom": "Zoom suave con la rueda",
|
||||
"ui.smooth_wheel_zoom_tip": "Desactívalo para aplicar cada movimiento de la rueda inmediatamente, sin suavizado posterior",
|
||||
"ui.snap": "Referencia a objetos",
|
||||
"ui.space_mouse_calibrate": "Calibrar…",
|
||||
"ui.space_mouse_calibrate_tip": "Mueve el capuchón como pide la ventana para que SolidCraft aprenda hacia dónde y hasta dónde va cada eje de este ratón 3D. Se guarda para este ratón 3D.",
|
||||
"ui.space_mouse_calibrate_title": "Calibrar ratón 3D",
|
||||
"ui.space_mouse_calibrated": "Calibrado",
|
||||
"ui.space_mouse_calibration": "Calibración del ratón 3D",
|
||||
"ui.space_mouse_calibration_done": "Listo. Guárdala para usarla siempre que este ratón 3D esté conectado.",
|
||||
"ui.space_mouse_calibration_no_response": "Un eje apenas se movió, así que no se puede calibrar. Vuelve a empezar y mueve el capuchón más lejos cada vez.",
|
||||
"ui.space_mouse_dead_zone": "Zona muerta",
|
||||
"ui.space_mouse_dead_zone_tip": "Cuánto tiene que moverse el capuchón antes de que se mueva la vista, como parte de su recorrido completo. Auméntala si la vista se desplaza con el capuchón en reposo.",
|
||||
"ui.space_mouse_forget": "Olvidar calibración",
|
||||
"ui.space_mouse_hint": "Desplaza y gira la vista a la vez; los bocetos solo permiten desplazamiento y zoom.",
|
||||
"ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "No se encontró ningún ratón 3D",
|
||||
"ui.space_mouse_not_calibrated": "Sin calibrar",
|
||||
"ui.space_mouse_reading": "Lectura",
|
||||
"ui.space_mouse_step_lift": "Levanta el capuchón, mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_press": "Presiona el capuchón hacia abajo, mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_pull": "Tira del capuchón hacia ti, mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_push": "Empuja el capuchón lejos de ti, mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_rest": "Suelta el capuchón y déjalo quieto.",
|
||||
"ui.space_mouse_step_slide_left": "Desliza el capuchón a la izquierda, mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_slide_right": "Desliza el capuchón a la derecha, mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_tilt_back": "Inclina el capuchón hacia atrás (borde delantero arriba), mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_tilt_forward": "Inclina el capuchón hacia delante (borde delantero abajo), mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_tilt_left": "Inclina el capuchón a la izquierda (borde izquierdo abajo), mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_tilt_right": "Inclina el capuchón a la derecha (borde derecho abajo), mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Gira el capuchón en sentido horario, mantenlo y suéltalo.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Gira el capuchón en sentido antihorario, mantenlo y suéltalo.",
|
||||
"ui.spacing": "Espaciado",
|
||||
"ui.spacing_2": "Espaciado 2",
|
||||
"ui.sphere": "Esfera",
|
||||
@@ -1424,6 +1465,7 @@
|
||||
"ui.split_body": "Dividir cuerpo",
|
||||
"ui.stand_up_tip": "Tras abrir un archivo STEP o IGES con Z hacia arriba, ofrecer un cuarto de vuelta alrededor de X",
|
||||
"ui.start": "Iniciar",
|
||||
"ui.start_over": "Volver a empezar",
|
||||
"ui.start_radius": "Radio inicial",
|
||||
"ui.stretch": "Estirar",
|
||||
"ui.supersampling": "Supermuestreo (SSAA)",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"Arc length": "Longueur d'arc",
|
||||
"Area": "Aire",
|
||||
"Attachment": "Rattachement",
|
||||
"BOUNDARY SURFACE": "SURFACE FRONTIÈRE",
|
||||
"BROWSER": "NAVIGATEUR",
|
||||
"Bend Line Position": "Position de la ligne de pli",
|
||||
"Bend Position": "Position du pli",
|
||||
@@ -73,10 +74,12 @@
|
||||
"Counterbore depth": "Profondeur du lamage",
|
||||
"Countersink angle": "Angle de la fraisure",
|
||||
"Create Issue — review draft": "Créer un ticket — relire le brouillon",
|
||||
"Cross curves": "Courbes transversales",
|
||||
"Cube": "Cube",
|
||||
"Current orthographic view (1:1 mm)": "Vue orthographique actuelle (1:1 mm)",
|
||||
"Current perspective view": "Vue en perspective actuelle",
|
||||
"Curvature (G2)": "Courbure (G2)",
|
||||
"Curvature change (G3)": "Variation de courbure (G3)",
|
||||
"Customize Toolbar": "Personnaliser la barre d'outils",
|
||||
"Delete all": "Tout supprimer",
|
||||
"Delete rule": "Supprimer la règle",
|
||||
@@ -105,6 +108,7 @@
|
||||
"Export Current View as DXF…": "Exporter la vue actuelle en DXF…",
|
||||
"Export DXF…": "Exporter en DXF…",
|
||||
"Export hidden edges as dashed lines": "Exporter les arêtes cachées en tirets",
|
||||
"Extend to meet": "Prolonger jusqu'à la rencontre",
|
||||
"Extents": "Étendues",
|
||||
"Face": "Face",
|
||||
"Face attachment lost": "Rattachement à la face perdu",
|
||||
@@ -119,6 +123,7 @@
|
||||
"Follow camera (Look At sketches)": "Suivre la caméra (vue normale aux esquisses)",
|
||||
"Follow the camera (Look At sketches)": "Suivre la caméra (vue normale aux esquisses)",
|
||||
"Font": "Police",
|
||||
"From edges": "Arêtes de départ",
|
||||
"Full Length": "Longueur totale",
|
||||
"Grid": "Grille",
|
||||
"Height": "Hauteur",
|
||||
@@ -133,7 +138,9 @@
|
||||
"Interferes": "Interfère",
|
||||
"Issue title": "Titre du ticket",
|
||||
"Joint Origin1": "Origine de liaison1",
|
||||
"Keep (points on it)": "À conserver (points dessus)",
|
||||
"Keep Tools": "Conserver les outils",
|
||||
"Keep of the other surface": "À conserver de l'autre surface",
|
||||
"Later": "Plus tard",
|
||||
"Leave it": "Laisser tel quel",
|
||||
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Rattachement à la face hérité. Le relier à une face certifiée crée un rattachement exact.",
|
||||
@@ -171,6 +178,7 @@
|
||||
"Open GitHub draft": "Ouvrir le brouillon GitHub",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAO 3D paramétrique open source en Rust. Sous licence MIT OR Apache-2.0.",
|
||||
"Other side": "Autre côté",
|
||||
"Other surface (mutual trim)": "Autre surface (ajustement mutuel)",
|
||||
"Outside": "Extérieur",
|
||||
"Overall length": "Longueur hors tout",
|
||||
"Parameter values": "Valeurs des paramètres",
|
||||
@@ -258,10 +266,14 @@
|
||||
"Stand this part up?": "Redresser cette pièce ?",
|
||||
"Step": "Étape",
|
||||
"Steps": "Étapes",
|
||||
"Stitch the kept pieces": "Coudre les parties conservées",
|
||||
"Stitch to both bodies": "Coudre aux deux corps",
|
||||
"Study": "Étude",
|
||||
"Style": "Style",
|
||||
"Suppressed": "Désactivé",
|
||||
"Surface": "Surface",
|
||||
"Swap Ends": "Permuter les extrémités",
|
||||
"TRIM": "AJUSTER",
|
||||
"Tangent (G1)": "Tangence (G1)",
|
||||
"Tangent Chain": "Chaîne tangente",
|
||||
"Tear": "Déchirure",
|
||||
@@ -277,8 +289,10 @@
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "Cet enfant est partagé. Les changements de position s'appliquent à toutes les copies de son parent.",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Cette face ne peut pas fournir de rattachement durable. Choisissez une autre face ou utilisez explicitement un plan fixe.",
|
||||
"To Offset": "Jusqu'au décalage",
|
||||
"To edges": "Arêtes d'arrivée",
|
||||
"Tolerance": "Tolérance",
|
||||
"Too close": "Trop proches",
|
||||
"Trimming tool (plane, face or curves)": "Outil d'ajustement (plan, face ou courbes)",
|
||||
"Turn off section analysis before exporting the view.": "Désactivez l'analyse de coupe avant d'exporter la vue.",
|
||||
"Type": "Type",
|
||||
"UNTRIM": "ANNULER L'AJUSTEMENT",
|
||||
@@ -440,6 +454,7 @@
|
||||
"cmd.joint_rigid_group": "Groupe rigide",
|
||||
"cmd.joint_screw": "Vis",
|
||||
"cmd.joint_solve": "Résoudre les liaisons",
|
||||
"cmd.joint_sweep": "Balayer une liaison",
|
||||
"cmd.library_apply": "Appliquer une entrée de la bibliothèque locale",
|
||||
"cmd.library_export": "Exporter la bibliothèque de la conception",
|
||||
"cmd.library_inspect": "Inspecter la bibliothèque locale",
|
||||
@@ -557,6 +572,8 @@
|
||||
"cmd.sketch_isoparametric_curve": "Courbe isoparamétrique",
|
||||
"cmd.sketch_line3d": "Ligne 3D",
|
||||
"cmd.sketch_line_midpoint": "Ligne par point milieu",
|
||||
"cmd.sketch_loose_ends": "Extrémités libres",
|
||||
"cmd.sketch_loose_ends_tie": "Faire coïncider les extrémités libres",
|
||||
"cmd.sketch_move": "Déplacer/Copier des entités d'esquisse",
|
||||
"cmd.sketch_move3d": "Déplacer un point 3D",
|
||||
"cmd.sketch_move_point": "Faire glisser un point d'esquisse",
|
||||
@@ -628,8 +645,10 @@
|
||||
"cmd.timeline_roll_to": "Déplacer le marqueur d'historique",
|
||||
"cmd.timeline_rollback": "Déplacer le marqueur de chronologie",
|
||||
"cmd.timeline_suppress": "Désactiver la fonction",
|
||||
"cmd.view_ambient_occlusion": "Occlusion ambiante",
|
||||
"cmd.view_apply": "Restaurer la vue nommée",
|
||||
"cmd.view_delete": "Supprimer la vue nommée",
|
||||
"cmd.view_graphics_preset": "Préréglage graphique",
|
||||
"cmd.view_list": "Lister les vues nommées",
|
||||
"cmd.view_navigate": "Naviguer dans la vue",
|
||||
"cmd.view_rename": "Renommer la vue nommée",
|
||||
@@ -726,9 +745,11 @@
|
||||
"new configuration": "nouvelle configuration",
|
||||
"new name": "nouveau nom",
|
||||
"new parameter": "nouveau paramètre",
|
||||
"no hole": "sans perçage",
|
||||
"no interference": "aucune interférence",
|
||||
"no joint that moves yet": "aucune liaison mobile pour l'instant",
|
||||
"no picture": "aucune image",
|
||||
"none": "aucun",
|
||||
"pattern.alignment_2": "Alignement 2",
|
||||
"pattern.alignment_hint": "La quantité inclut l’original. Le centrage répartit les copies de chaque côté ; si la quantité est paire, l’inversion choisit le côté qui reçoit la copie supplémentaire.",
|
||||
"pattern.direction_1": "Direction 1",
|
||||
@@ -801,6 +822,8 @@
|
||||
"sketch.degrees_of_freedom": "{count} degrés de liberté",
|
||||
"sketch.draw_curves": "Dessiner en {linetype}",
|
||||
"sketch.make_selection": "Convertir la sélection en {linetype}",
|
||||
"sketch.open_ends": "{count} ouvertes",
|
||||
"sketch.untied_ends": "{count} sans coïncidence",
|
||||
"sketch_text.align_center": "Centre",
|
||||
"sketch_text.align_left": "Gauche",
|
||||
"sketch_text.align_right": "Droite",
|
||||
@@ -813,6 +836,7 @@
|
||||
"sketch_text.path_clear": "Détacher le texte de sa courbe",
|
||||
"sketch_text.path_hint": "Une courbe de l'esquisse que le texte suit : cliquer ici, puis sur la courbe",
|
||||
"sketch_text.use_font_file": "Utiliser le fichier de police {path}",
|
||||
"solid": "plein (sans paroi)",
|
||||
"the parts library is not available": "la bibliothèque de pièces n'est pas disponible",
|
||||
"thread.automatic_size": "Taille automatique",
|
||||
"thread.designation": "Désignation",
|
||||
@@ -824,6 +848,7 @@
|
||||
"timeline.rename_group": "Renommer le dossier de fonctions",
|
||||
"timeline.reorder_many": "Réordonner les fonctions",
|
||||
"timeline.ungroup": "Supprimer les dossiers de fonctions",
|
||||
"to the face": "jusqu'à la face",
|
||||
"to the next face": "jusqu'à la face suivante",
|
||||
"toolbar.image": "IMAGE",
|
||||
"toolbar.render": "RENDU",
|
||||
@@ -839,12 +864,15 @@
|
||||
"ui.activate": "Activer",
|
||||
"ui.active_component": "Composant actif",
|
||||
"ui.agent_chat": "Chat de l'agent",
|
||||
"ui.agent_chat_add": "Joindre des fichiers",
|
||||
"ui.agent_chat_add_file": "Choisir un fichier…",
|
||||
"ui.agent_chat_allow": "Autoriser",
|
||||
"ui.agent_chat_allowed": "Autorisé",
|
||||
"ui.agent_chat_answered": "Réponse : {option}",
|
||||
"ui.agent_chat_approval": "Autoriser l'agent à effectuer cette action ?",
|
||||
"ui.agent_chat_attach_view": "Joindre la vue",
|
||||
"ui.agent_chat_attach_view_tip": "Envoyer une image de la vue actuelle avec le message",
|
||||
"ui.agent_chat_attachable": "Images, PDF, textes et fichiers de CAO",
|
||||
"ui.agent_chat_auth_agent": "S'authentifier avec {name}",
|
||||
"ui.agent_chat_auth_env": "Définir {vars} dans votre environnement, puis redémarrer SolidCraft.",
|
||||
"ui.agent_chat_auth_needed": "L'agent demande une authentification. Vous vous authentifiez auprès de l'agent lui-même ; SolidCraft ne voit jamais votre mot de passe ni vos clés.",
|
||||
@@ -864,11 +892,32 @@
|
||||
"ui.agent_chat_deny": "Refuser",
|
||||
"ui.agent_chat_desktop_only": "Le chat de l'agent nécessite l'application de bureau.",
|
||||
"ui.agent_chat_detecting": "Recherche d'agents…",
|
||||
"ui.agent_chat_dock": "Ancrer dans le panneau latéral",
|
||||
"ui.agent_chat_dock_left": "Ancrer à gauche",
|
||||
"ui.agent_chat_dock_right": "Ancrer à droite",
|
||||
"ui.agent_chat_drop_hint": "Déposer pour joindre à votre prochain message",
|
||||
"ui.agent_chat_edit": "Modifier",
|
||||
"ui.agent_chat_exited": "L'agent s'est arrêté.",
|
||||
"ui.agent_chat_failed_start": "L'agent n'a pas pu démarrer : {error}",
|
||||
"ui.agent_chat_file_cad_not_read": "{name} : un fichier de CAO est importé dans la conception, pas lu",
|
||||
"ui.agent_chat_file_empty_picture": "{name} : image vide",
|
||||
"ui.agent_chat_file_error": "{name} : {error}",
|
||||
"ui.agent_chat_file_heic": "{name} : les images HEIC ne peuvent pas être lues ici ; les exporter d'abord en JPEG ou PNG",
|
||||
"ui.agent_chat_file_importing": "Importation dans la conception…",
|
||||
"ui.agent_chat_file_loading": "Lecture du fichier…",
|
||||
"ui.agent_chat_file_not_a_file": "{name} : n'est pas un fichier",
|
||||
"ui.agent_chat_file_not_a_picture": "{name} : n'est pas une image",
|
||||
"ui.agent_chat_file_not_utf8": "{name} : n'est pas un texte UTF-8",
|
||||
"ui.agent_chat_file_picture_too_big": "{name} : une image ne peut pas dépasser {max} Mo",
|
||||
"ui.agent_chat_file_text_too_big": "{name} : un fichier texte ne peut pas dépasser {max} Ko",
|
||||
"ui.agent_chat_file_too_large": "{name} : trop volumineux",
|
||||
"ui.agent_chat_file_too_large_to_send": "{name} : trop volumineux pour être envoyé, même réduit",
|
||||
"ui.agent_chat_file_unreadable_picture": "{name} : image illisible ici",
|
||||
"ui.agent_chat_files": "Fichiers : {files}",
|
||||
"ui.agent_chat_files_left_out": "Un message ne peut contenir que {max} fichiers au plus ; non joints : {files}",
|
||||
"ui.agent_chat_files_loading": "Lecture des fichiers en cours",
|
||||
"ui.agent_chat_files_max": "{max} fichiers au plus par message",
|
||||
"ui.agent_chat_import_failed": "{name} : l'importation a échoué : {error}",
|
||||
"ui.agent_chat_intro": "Discuter avec un agent de programmation que vous utilisez déjà, ou avec un point d'accès de modèle de votre choix. Il travaille sur la conception ouverte avec les outils de SolidCraft : ses modifications apparaissent au fur et à mesure et s'annulent en une seule étape par message. Ce qu'il peut faire sans demander se règle dans Préférences › IA.",
|
||||
"ui.agent_chat_key_in_env": "clé définie",
|
||||
"ui.agent_chat_left": "Gauche",
|
||||
@@ -881,14 +930,18 @@
|
||||
"ui.agent_chat_no_cli": "solidcraft-cli est introuvable à côté de l'application : l'agent ne peut donc pas accéder à la conception.",
|
||||
"ui.agent_chat_no_control": "Le point d'accès du chat de l'agent n'a pas démarré : l'agent ne peut donc pas accéder à la conception.",
|
||||
"ui.agent_chat_no_images": "Cet agent n'accepte pas les images",
|
||||
"ui.agent_chat_no_picture": "Il n'y a pas d'image dans le presse-papiers",
|
||||
"ui.agent_chat_none_found": "Aucun agent de programmation trouvé. En installer un parmi ceux-ci, puis appuyer sur Actualiser :",
|
||||
"ui.agent_chat_none_found_short": "Aucun agent trouvé",
|
||||
"ui.agent_chat_not_installed": "Non installé",
|
||||
"ui.agent_chat_not_signed_in": "non authentifié",
|
||||
"ui.agent_chat_own_window": "Fenêtre séparée",
|
||||
"ui.agent_chat_paste_picture": "Coller une image",
|
||||
"ui.agent_chat_permission": "L'agent demande à exécuter : {title}",
|
||||
"ui.agent_chat_pick": "Agent",
|
||||
"ui.agent_chat_placeholder": "Demander à l'agent de modifier la conception…",
|
||||
"ui.agent_chat_placeholder_busy": "Mettre un message en file d'attente pour quand l'agent aura terminé (Ctrl+Enter : envoyer maintenant)",
|
||||
"ui.agent_chat_pop_out": "Ouvrir dans une fenêtre séparée, déplaçable vers un autre écran",
|
||||
"ui.agent_chat_privacy": "Rien n'est envoyé ailleurs qu'à l'agent que vous lancez, sous votre propre compte.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft n'envoie rien nulle part : l'agent que vous lancez dialogue avec son propre service, sous votre propre compte. SolidCraft ne voit ni ne stocke jamais de clé ni de mot de passe.",
|
||||
"ui.agent_chat_queue": "Mettre en file",
|
||||
@@ -898,6 +951,8 @@
|
||||
"ui.agent_chat_refresh": "Actualiser",
|
||||
"ui.agent_chat_refused": "Interdit à l'agent : {reason}",
|
||||
"ui.agent_chat_remove": "Retirer de la file d'attente",
|
||||
"ui.agent_chat_remove_file": "Retirer de ce message",
|
||||
"ui.agent_chat_remove_imported": "Retirer de ce message ; la pièce reste dans la conception (Annuler la retire)",
|
||||
"ui.agent_chat_right": "Droite",
|
||||
"ui.agent_chat_save": "Enregistrer",
|
||||
"ui.agent_chat_send": "Envoyer",
|
||||
@@ -995,6 +1050,8 @@
|
||||
"ui.ai_toolbar_button_note": "Le bouton IA à l'extrémité droite de la barre d'outils. Ctrl+Shift+A et le menu Vue ouvrent toujours le panneau.",
|
||||
"ui.ai_undo_tip": "Annuler tout ce que l'agent a modifié pour ce message, en une seule étape",
|
||||
"ui.all": "Tout",
|
||||
"ui.ambient_occlusion": "Occlusion ambiante",
|
||||
"ui.ambient_occlusion_help": "Ombrage doux aux jonctions des surfaces opaques. Nécessite la vue GPU.",
|
||||
"ui.analysis": "Analyse",
|
||||
"ui.angle": "Angle",
|
||||
"ui.angle_decimals": "Décimales des angles",
|
||||
@@ -1009,6 +1066,7 @@
|
||||
"ui.autosave_every": "Enregistrement automatique toutes les",
|
||||
"ui.axes": "Axes",
|
||||
"ui.axis": "Axe",
|
||||
"ui.back": "Retour",
|
||||
"ui.base_point": "Point de base",
|
||||
"ui.blocked": "Inaccessible",
|
||||
"ui.bodies": "Corps",
|
||||
@@ -1155,6 +1213,11 @@
|
||||
"ui.fully_constrained": "Entièrement contrainte",
|
||||
"ui.general": "Général",
|
||||
"ui.graphics": "Graphisme",
|
||||
"ui.graphics_custom": "Personnalisé",
|
||||
"ui.graphics_performance": "Performances",
|
||||
"ui.graphics_preset": "Préréglage graphique",
|
||||
"ui.graphics_preset_help": "Qualité : occlusion ambiante, tessellation fine et suréchantillonnage adaptatif 2×. Performances : sans occlusion ambiante, tessellation normale et sans suréchantillonnage. Les réglages manuels affichent Personnalisé. MSAA reste inchangé.",
|
||||
"ui.graphics_quality": "Qualité",
|
||||
"ui.grid_follows_view": "La grille suit la vue",
|
||||
"ui.grid_follows_view_hint": "Dans les vues de côté, la grille s'affiche sur le plan vertical qui leur fait face plutôt que sur le sol",
|
||||
"ui.ground_shadow": "Ombre au sol",
|
||||
@@ -1296,6 +1359,7 @@
|
||||
"ui.newSketchGroup": "Nouveau groupe d'esquisses",
|
||||
"ui.new_body": "Nouveau corps",
|
||||
"ui.new_design": "Nouvelle conception",
|
||||
"ui.no_geometry_yet": "Aucune géométrie pour l'instant",
|
||||
"ui.no_slicer_found": "Aucun slicer trouvé",
|
||||
"ui.normal": "Normal",
|
||||
"ui.normalConstruction": "Normal / Construction",
|
||||
@@ -1308,6 +1372,7 @@
|
||||
"ui.one_side": "Un côté",
|
||||
"ui.open": "Ouvrir",
|
||||
"ui.open_ellipsis": "Ouvrir...",
|
||||
"ui.open_ends_tip": "Ne touche rien",
|
||||
"ui.open_preferences": "Ouvrir les préférences",
|
||||
"ui.open_recent": "Ouvrir un fichier récent",
|
||||
"ui.operation": "Opération",
|
||||
@@ -1369,6 +1434,8 @@
|
||||
"ui.reset_the_settings_on_this_page": "Réinitialiser les réglages de cette page",
|
||||
"ui.restore": "Restaurer",
|
||||
"ui.restore_defaults": "Rétablir les valeurs par défaut",
|
||||
"ui.reverse_horizontal_pan_direction": "Inverser le sens du panoramique horizontal",
|
||||
"ui.reverse_horizontal_pan_tip": "Le défilement horizontal à deux doigts et la molette latérale font le panoramique dans l'autre sens",
|
||||
"ui.reverse_zoom_direction": "Inverser le sens du zoom",
|
||||
"ui.revolve": "Révolution",
|
||||
"ui.right": "Droite",
|
||||
@@ -1379,6 +1446,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Sélectionner les corps à enregistrer",
|
||||
"ui.save_mesh_temp_failed": "Impossible de créer {folder} : {error}",
|
||||
"ui.saved_to": "Enregistré : {path}",
|
||||
"ui.sbox_pin": "Épingler en haut de la boîte S",
|
||||
"ui.sbox_unpin": "Désépingler",
|
||||
"ui.scale": "Échelle",
|
||||
"ui.second_rail": "Second rail",
|
||||
"ui.section_analysis": "Analyse de coupe",
|
||||
@@ -1422,8 +1491,34 @@
|
||||
"ui.smooth_wheel_zoom": "Zoom fluide à la molette",
|
||||
"ui.smooth_wheel_zoom_tip": "Désactiver pour appliquer chaque cran de molette immédiatement, sans lissage",
|
||||
"ui.snap": "Accrochage",
|
||||
"ui.space_mouse_calibrate": "Calibrer…",
|
||||
"ui.space_mouse_calibrate_tip": "Déplacez le capuchon comme la fenêtre le demande, pour que SolidCraft apprenne dans quel sens et jusqu'où va chaque axe de cette souris 3D. Enregistré pour cette souris 3D.",
|
||||
"ui.space_mouse_calibrate_title": "Calibrer la souris 3D",
|
||||
"ui.space_mouse_calibrated": "Calibrée",
|
||||
"ui.space_mouse_calibration": "Calibrage de la souris 3D",
|
||||
"ui.space_mouse_calibration_done": "Terminé. Enregistrez-le pour l'utiliser chaque fois que cette souris 3D est connectée.",
|
||||
"ui.space_mouse_calibration_no_response": "Un axe a à peine bougé, il ne peut donc pas être calibré. Recommencez en déplaçant le capuchon plus loin à chaque fois.",
|
||||
"ui.space_mouse_dead_zone": "Zone morte",
|
||||
"ui.space_mouse_dead_zone_tip": "De combien le capuchon doit bouger avant que la vue ne bouge, en part de sa course complète. Augmentez-la si la vue dérive alors que le capuchon est au repos.",
|
||||
"ui.space_mouse_forget": "Oublier le calibrage",
|
||||
"ui.space_mouse_hint": "Déplace et fait pivoter la vue en même temps ; dans les esquisses, seuls le panoramique et le zoom sont utilisés.",
|
||||
"ui.space_mouse_navigate": "Naviguer avec SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "Aucune souris 3D trouvée",
|
||||
"ui.space_mouse_not_calibrated": "Non calibrée",
|
||||
"ui.space_mouse_reading": "Lecture",
|
||||
"ui.space_mouse_step_lift": "Soulevez le capuchon, maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_press": "Appuyez sur le capuchon, maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_pull": "Tirez le capuchon vers vous, maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_push": "Poussez le capuchon loin de vous, maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_rest": "Lâchez le capuchon et laissez-le immobile.",
|
||||
"ui.space_mouse_step_slide_left": "Glissez le capuchon vers la gauche, maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_slide_right": "Glissez le capuchon vers la droite, maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_tilt_back": "Inclinez le capuchon vers l'arrière (bord avant vers le haut), maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_tilt_forward": "Inclinez le capuchon vers l'avant (bord avant vers le bas), maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_tilt_left": "Inclinez le capuchon vers la gauche (bord gauche vers le bas), maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_tilt_right": "Inclinez le capuchon vers la droite (bord droit vers le bas), maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Tournez le capuchon dans le sens horaire, maintenez, puis relâchez.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Tournez le capuchon dans le sens antihoraire, maintenez, puis relâchez.",
|
||||
"ui.spacing": "Espacement",
|
||||
"ui.spacing_2": "Espacement 2",
|
||||
"ui.sphere": "Sphère",
|
||||
@@ -1431,6 +1526,7 @@
|
||||
"ui.split_body": "Scinder le corps",
|
||||
"ui.stand_up_tip": "Après l'ouverture d'un fichier STEP ou IGES avec Z vers le haut, proposer un quart de tour autour de X",
|
||||
"ui.start": "Début",
|
||||
"ui.start_over": "Recommencer",
|
||||
"ui.start_radius": "Rayon initial",
|
||||
"ui.stretch": "Étirer",
|
||||
"ui.supersampling": "Suréchantillonnage (SSAA)",
|
||||
@@ -1451,6 +1547,8 @@
|
||||
"ui.theme": "Thème",
|
||||
"ui.thickness": "Épaisseur",
|
||||
"ui.thread": "Filetage",
|
||||
"ui.tie_ends": "Coïncider",
|
||||
"ui.tie_ends_tip": "Ajouter une coïncidence à toutes les extrémités qui n'en ont pas",
|
||||
"ui.timeline_bottom": "Chronologie en bas",
|
||||
"ui.timeline_left_note": "La disposition à gauche affiche la barre de retour en arrière au-dessus du navigateur des pièces et de la scène.",
|
||||
"ui.timeline_left_sidebar": "Barre latérale gauche",
|
||||
@@ -1473,6 +1571,7 @@
|
||||
"ui.units_of_new_designs": "Unités des nouvelles conceptions",
|
||||
"ui.unlock": "Déverrouiller",
|
||||
"ui.unsuppress": "Réactiver",
|
||||
"ui.untied_ends_tip": "Ne touche que par la position",
|
||||
"ui.untitled": "Sans titre",
|
||||
"ui.up_axis_note": "Axe orienté vers le haut dans les vues (vue d'axe, Accueil, Vue normale). Les conceptions ne sont pas modifiées.",
|
||||
"ui.updateToCurrentView": "Mettre à jour avec la vue actuelle",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Current orthographic view (1:1 mm)": "Vista ortográfica actual (1:1 mm)",
|
||||
"Current perspective view": "Vista en perspectiva actual",
|
||||
"Curvature (G2)": "Curvatura (G2)",
|
||||
"Curvature change (G3)": "Cambio de curvatura (G3)",
|
||||
"Customize Toolbar": "Personalizar a barra de ferramentas",
|
||||
"Delete all": "Eliminar todo",
|
||||
"Delete rule": "Eliminar a regra",
|
||||
@@ -98,6 +99,7 @@
|
||||
"Export Current View as DXF…": "Exportar a vista actual como DXF…",
|
||||
"Export DXF…": "Exportar DXF…",
|
||||
"Export hidden edges as dashed lines": "Exportar as arestas ocultas como liñas descontinuas",
|
||||
"Extend to meet": "Estender ata atopala",
|
||||
"Extents": "Extensións",
|
||||
"Face": "Cara",
|
||||
"Face attachment lost": "Perdeuse a ancoraxe á cara",
|
||||
@@ -112,6 +114,7 @@
|
||||
"Follow camera (Look At sketches)": "Seguir a cámara (Mirar cara aos bosquexos)",
|
||||
"Follow the camera (Look At sketches)": "Seguir a cámara (Mirar cara aos bosquexos)",
|
||||
"Font": "Tipo de letra",
|
||||
"From edges": "Arestas de orixe",
|
||||
"Full Length": "Lonxitude completa",
|
||||
"Grid": "Grella",
|
||||
"Height": "Altura",
|
||||
@@ -126,7 +129,9 @@
|
||||
"Interferes": "Interfire",
|
||||
"Issue title": "Título da incidencia",
|
||||
"Joint Origin1": "Orixe da unión1",
|
||||
"Keep (points on it)": "Manter (puntos sobre ela)",
|
||||
"Keep Tools": "Manter as ferramentas",
|
||||
"Keep of the other surface": "Manter da outra superficie",
|
||||
"Later": "Máis tarde",
|
||||
"Leave it": "Deixalo",
|
||||
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Ancoraxe á cara antiga. Volver vincular a unha cara certificada crea unha ancoraxe exacta.",
|
||||
@@ -162,6 +167,7 @@
|
||||
"Open GitHub draft": "Abrir o borrador de GitHub",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAD 3D paramétrico de código aberto en Rust. Con licenza MIT OR Apache-2.0.",
|
||||
"Other side": "Outro lado",
|
||||
"Other surface (mutual trim)": "Outra superficie (recorte mutuo)",
|
||||
"Outside": "Fóra",
|
||||
"Overall length": "Lonxitude total",
|
||||
"Parameter values": "Valores dos parámetros",
|
||||
@@ -245,9 +251,13 @@
|
||||
"Stand this part up?": "Poñer esta peza de pé?",
|
||||
"Step": "Paso",
|
||||
"Steps": "Pasos",
|
||||
"Stitch the kept pieces": "Coser as pezas mantidas",
|
||||
"Stitch to both bodies": "Coser aos dous corpos",
|
||||
"Style": "Estilo",
|
||||
"Suppressed": "Suprimida",
|
||||
"Surface": "Superficie",
|
||||
"Swap Ends": "Intercambiar os extremos",
|
||||
"TRIM": "RECORTAR",
|
||||
"Tangent (G1)": "Tanxente (G1)",
|
||||
"Tangent Chain": "Cadea tanxente",
|
||||
"Tear": "Rasgado",
|
||||
@@ -263,7 +273,9 @@
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "Este fillo está compartido. Os cambios de posición aplícanse a todas as copias do seu pai.",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara non pode ofrecer unha ancoraxe duradeira. Escolle outra cara ou usa expresamente un plano fixo.",
|
||||
"To Offset": "Ata o desprazamento",
|
||||
"To edges": "Arestas de destino",
|
||||
"Tolerance": "Tolerancia",
|
||||
"Trimming tool (plane, face or curves)": "Ferramenta de recorte (plano, cara ou curvas)",
|
||||
"Turn off section analysis before exporting the view.": "Desactiva a análise de sección antes de exportar a vista.",
|
||||
"Type": "Tipo",
|
||||
"UNTRIM": "DESRECORTAR",
|
||||
@@ -976,6 +988,7 @@
|
||||
"ui.autosave_every": "Gardar automaticamente cada",
|
||||
"ui.axes": "Eixes",
|
||||
"ui.axis": "Eixe",
|
||||
"ui.back": "Atrás",
|
||||
"ui.base_point": "Punto base",
|
||||
"ui.blocked": "Bloqueado",
|
||||
"ui.bodies": "Corpos",
|
||||
@@ -1337,6 +1350,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Selecciona os corpos que queres gardar",
|
||||
"ui.save_mesh_temp_failed": "Non se puido crear {folder}: {error}",
|
||||
"ui.saved_to": "Gardado {path}",
|
||||
"ui.sbox_pin": "Fixar arriba na caixa S",
|
||||
"ui.sbox_unpin": "Desfixar",
|
||||
"ui.scale": "Escala",
|
||||
"ui.section_analysis": "Análise de sección",
|
||||
"ui.select": "Seleccionar",
|
||||
@@ -1379,8 +1394,34 @@
|
||||
"ui.smooth_wheel_zoom": "Zoom suave coa roda",
|
||||
"ui.smooth_wheel_zoom_tip": "Desactívao para aplicar cada movemento da roda de inmediato, sen suavizado",
|
||||
"ui.snap": "Axuste",
|
||||
"ui.space_mouse_calibrate": "Calibrar…",
|
||||
"ui.space_mouse_calibrate_tip": "Move o capuchón como pide a xanela para que SolidCraft aprenda cara a onde e ata onde vai cada eixe deste rato 3D. Gárdase para este rato 3D.",
|
||||
"ui.space_mouse_calibrate_title": "Calibrar o rato 3D",
|
||||
"ui.space_mouse_calibrated": "Calibrado",
|
||||
"ui.space_mouse_calibration": "Calibración do rato 3D",
|
||||
"ui.space_mouse_calibration_done": "Feito. Gárdaa para usala sempre que este rato 3D estea conectado.",
|
||||
"ui.space_mouse_calibration_no_response": "Un eixe case non se moveu, así que non se pode calibrar. Volve comezar e move o capuchón máis lonxe cada vez.",
|
||||
"ui.space_mouse_dead_zone": "Zona morta",
|
||||
"ui.space_mouse_dead_zone_tip": "Canto ten que moverse o capuchón antes de que se mova a vista, como parte do seu percorrido completo. Auméntaa se a vista se despraza co capuchón en repouso.",
|
||||
"ui.space_mouse_forget": "Esquecer a calibración",
|
||||
"ui.space_mouse_hint": "Move e rota a vista á vez; os bosquexos só usan desprazamento e zoom.",
|
||||
"ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "Non se atopou ningún rato 3D",
|
||||
"ui.space_mouse_not_calibrated": "Sen calibrar",
|
||||
"ui.space_mouse_reading": "Lectura",
|
||||
"ui.space_mouse_step_lift": "Levanta o capuchón, mantéño e sóltao.",
|
||||
"ui.space_mouse_step_press": "Preme o capuchón cara abaixo, mantéño e sóltao.",
|
||||
"ui.space_mouse_step_pull": "Tira do capuchón cara a ti, mantéño e sóltao.",
|
||||
"ui.space_mouse_step_push": "Empurra o capuchón lonxe de ti, mantéño e sóltao.",
|
||||
"ui.space_mouse_step_rest": "Solta o capuchón e déixao quieto.",
|
||||
"ui.space_mouse_step_slide_left": "Despraza o capuchón á esquerda, mantéño e sóltao.",
|
||||
"ui.space_mouse_step_slide_right": "Despraza o capuchón á dereita, mantéño e sóltao.",
|
||||
"ui.space_mouse_step_tilt_back": "Inclina o capuchón cara atrás (bordo dianteiro arriba), mantéño e sóltao.",
|
||||
"ui.space_mouse_step_tilt_forward": "Inclina o capuchón cara adiante (bordo dianteiro abaixo), mantéño e sóltao.",
|
||||
"ui.space_mouse_step_tilt_left": "Inclina o capuchón á esquerda (bordo esquerdo abaixo), mantéño e sóltao.",
|
||||
"ui.space_mouse_step_tilt_right": "Inclina o capuchón á dereita (bordo dereito abaixo), mantéño e sóltao.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Xira o capuchón no sentido horario, mantéño e sóltao.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Xira o capuchón no sentido antihorario, mantéño e sóltao.",
|
||||
"ui.spacing": "Espazado",
|
||||
"ui.spacing_2": "Espazado 2",
|
||||
"ui.sphere": "Esfera",
|
||||
@@ -1388,6 +1429,7 @@
|
||||
"ui.split_body": "Dividir o corpo",
|
||||
"ui.stand_up_tip": "Despois de abrir un ficheiro STEP ou IGES con Z cara arriba, ofrecer un cuarto de volta arredor de X",
|
||||
"ui.start": "Inicio",
|
||||
"ui.start_over": "Volver comezar",
|
||||
"ui.start_radius": "Raio inicial",
|
||||
"ui.supersampling": "Supermostraxe (SSAA)",
|
||||
"ui.supersampling_adaptive": "Só cando a vista está quieta",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"Arc length": "Lunghezza dell'arco",
|
||||
"Area": "Area",
|
||||
"Attachment": "Collegamento",
|
||||
"BOUNDARY SURFACE": "SUPERFICIE DA CONTORNI",
|
||||
"BROWSER": "STRUTTURA",
|
||||
"Bend Line Position": "Posizione della linea di piega",
|
||||
"Bend Position": "Posizione della piega",
|
||||
@@ -73,10 +74,12 @@
|
||||
"Counterbore depth": "Profondità della lamatura",
|
||||
"Countersink angle": "Angolo di svasatura",
|
||||
"Create Issue — review draft": "Crea segnalazione — rivedi bozza",
|
||||
"Cross curves": "Curve trasversali",
|
||||
"Cube": "Cubo",
|
||||
"Current orthographic view (1:1 mm)": "Vista ortografica corrente (1:1 mm)",
|
||||
"Current perspective view": "Vista prospettica corrente",
|
||||
"Curvature (G2)": "Curvatura (G2)",
|
||||
"Curvature change (G3)": "Variazione di curvatura (G3)",
|
||||
"Customize Toolbar": "Personalizza barra degli strumenti",
|
||||
"Delete all": "Elimina tutto",
|
||||
"Delete rule": "Elimina regola",
|
||||
@@ -86,6 +89,7 @@
|
||||
"Density": "Densità",
|
||||
"Designs you open or save appear here.": "I progetti aperti o salvati vengono visualizzati qui.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Determinato da Fino all'oggetto. Scegli Angolo per modificare l'estensione angolare.",
|
||||
"Direction": "Direzione",
|
||||
"Discard": "Scarta",
|
||||
"Do not export hidden edges": "Non esportare gli spigoli nascosti",
|
||||
"Don't ask again": "Non chiedere più",
|
||||
@@ -118,6 +122,7 @@
|
||||
"Follow camera (Look At sketches)": "Segui la camera (Vista normale agli schizzi)",
|
||||
"Follow the camera (Look At sketches)": "Segui la camera (Vista normale agli schizzi)",
|
||||
"Font": "Carattere",
|
||||
"From edges": "Spigoli di partenza",
|
||||
"Full Length": "Lunghezza totale",
|
||||
"Grid": "Griglia",
|
||||
"Height": "Altezza",
|
||||
@@ -127,6 +132,7 @@
|
||||
"Home view": "Vista iniziale",
|
||||
"In This Design": "In questo progetto",
|
||||
"In place (surfaces)": "In posizione (superfici)",
|
||||
"In plane": "Nel piano",
|
||||
"Inside": "Interno",
|
||||
"Interferes": "Interferisce",
|
||||
"Issue title": "Titolo della segnalazione",
|
||||
@@ -162,6 +168,7 @@
|
||||
"No matching appearances": "Nessun aspetto corrispondente",
|
||||
"No visible bodies to export.": "Nessun corpo visibile da esportare.",
|
||||
"None": "Nessuno",
|
||||
"Normal to sketch": "Normale allo schizzo",
|
||||
"Not available yet": "Non ancora disponibile",
|
||||
"Notch": "Intaglio",
|
||||
"Opacity": "Opacità",
|
||||
@@ -255,6 +262,7 @@
|
||||
"Stand this part up?": "Raddrizzare questa parte?",
|
||||
"Step": "Passo",
|
||||
"Steps": "Passi",
|
||||
"Stitch to both bodies": "Cuci a entrambi i corpi",
|
||||
"Study": "Studio",
|
||||
"Style": "Stile",
|
||||
"Suppressed": "Sospeso",
|
||||
@@ -274,6 +282,7 @@
|
||||
"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.",
|
||||
"To Offset": "Fino a una distanza",
|
||||
"To edges": "Spigoli di arrivo",
|
||||
"Tolerance": "Tolleranza",
|
||||
"Too close": "Troppo vicini",
|
||||
"Turn off section analysis before exporting the view.": "Disattiva l'analisi in sezione prima di esportare la vista.",
|
||||
@@ -345,6 +354,7 @@
|
||||
"cmd.component_move_sketches": "Sposta schizzi nel componente",
|
||||
"cmd.component_paste_new": "Incolla come nuovo",
|
||||
"cmd.component_properties": "Proprietà fisiche del componente",
|
||||
"cmd.component_relink": "Ricollega sorgente",
|
||||
"cmd.component_rename": "Rinomina componente",
|
||||
"cmd.component_revert_position": "Ripristina posizione",
|
||||
"cmd.component_unlock": "Sblocca contenuto",
|
||||
@@ -419,6 +429,7 @@
|
||||
"cmd.inspect_interference": "Interferenza",
|
||||
"cmd.inspect_isocurve": "Analisi delle isocurve",
|
||||
"cmd.inspect_minimum_radius": "Analisi del raggio minimo",
|
||||
"cmd.inspect_silhouette": "Silhouette",
|
||||
"cmd.inspect_zebra": "Analisi zebrata",
|
||||
"cmd.joint_as_built": "Giunto in posizione",
|
||||
"cmd.joint_cam": "Camma",
|
||||
@@ -553,6 +564,8 @@
|
||||
"cmd.sketch_isoparametric_curve": "Curva isoparametrica",
|
||||
"cmd.sketch_line3d": "Linea 3D",
|
||||
"cmd.sketch_line_midpoint": "Linea dal punto medio",
|
||||
"cmd.sketch_loose_ends": "Estremità libere",
|
||||
"cmd.sketch_loose_ends_tie": "Vincola estremità libere",
|
||||
"cmd.sketch_move": "Sposta/Copia entità dello schizzo",
|
||||
"cmd.sketch_move3d": "Sposta punto 3D",
|
||||
"cmd.sketch_move_point": "Trascina punto dello schizzo",
|
||||
@@ -703,6 +716,15 @@
|
||||
"joint.from_bodies_hint": "Ogni corpo selezionato viene spostato in un nuovo componente distinto, mantenendo la sua posizione.",
|
||||
"joint.one_body": "Entrambe le selezioni sono su {body}. Seleziona il secondo elemento su un'altra parte.",
|
||||
"joint.one_component": "Queste parti sono corpi di un unico componente: {bodies}. Un giunto muove un componente rispetto a un altro, quindi ogni parte richiede un componente distinto.",
|
||||
"messages.ago_h": "{n} h fa",
|
||||
"messages.ago_min": "{n} min fa",
|
||||
"messages.ago_s": "{n} s fa",
|
||||
"messages.chip_hint": "Fai clic per tutti i messaggi di questa sessione",
|
||||
"messages.clear": "Cancella",
|
||||
"messages.copy_all": "Copia tutto",
|
||||
"messages.menu": "Messaggi…",
|
||||
"messages.none": "Ancora nessun messaggio.",
|
||||
"messages.title": "Messaggi",
|
||||
"motion.checked_to": "Controllato solo fino al passo {step}: il controllo ha esaurito il limite di calcolo",
|
||||
"motion.collision": "{a} · {b}: passi {steps}",
|
||||
"motion.duration_hint": "secondi (vuoto: per passo)",
|
||||
@@ -792,6 +814,8 @@
|
||||
"sketch.degrees_of_freedom": "{count} gradi di libertà",
|
||||
"sketch.draw_curves": "Disegna come {linetype}",
|
||||
"sketch.make_selection": "Converti la selezione in {linetype}",
|
||||
"sketch.open_ends": "Aperte {count}",
|
||||
"sketch.untied_ends": "Non vincolate {count}",
|
||||
"sketch_text.align_center": "Centro",
|
||||
"sketch_text.align_left": "Sinistra",
|
||||
"sketch_text.align_right": "Destra",
|
||||
@@ -1000,6 +1024,7 @@
|
||||
"ui.autosave_every": "Salvataggio automatico ogni",
|
||||
"ui.axes": "Assi",
|
||||
"ui.axis": "Asse",
|
||||
"ui.back": "Indietro",
|
||||
"ui.base_point": "Punto base",
|
||||
"ui.blocked": "Bloccato",
|
||||
"ui.bodies": "Corpi",
|
||||
@@ -1054,6 +1079,9 @@
|
||||
"ui.construct": "Costruisci",
|
||||
"ui.construction": "Costruzione",
|
||||
"ui.context_menu": "Menu contestuale",
|
||||
"ui.continuity_g0": "G0 spigolo vivo",
|
||||
"ui.continuity_g1": "G1 tangenza",
|
||||
"ui.continuity_g2": "G2 curvatura",
|
||||
"ui.continuity_gap": "Gioco",
|
||||
"ui.copy": "Copia",
|
||||
"ui.corner": "Angolo",
|
||||
@@ -1142,6 +1170,8 @@
|
||||
"ui.fully_constrained": "Completamente vincolato",
|
||||
"ui.general": "Generali",
|
||||
"ui.graphics": "Grafica",
|
||||
"ui.grid_follows_view": "La griglia segue la vista",
|
||||
"ui.grid_follows_view_hint": "Le viste laterali mostrano la griglia sul piano verticale rivolto verso di esse anziché sul piano di appoggio",
|
||||
"ui.ground_shadow": "Ombra sul piano di appoggio",
|
||||
"ui.groupSelected": "Raggruppa selezionati",
|
||||
"ui.heading_add_ins": "COMPONENTI AGGIUNTIVI",
|
||||
@@ -1292,6 +1322,7 @@
|
||||
"ui.one_side": "Un lato",
|
||||
"ui.open": "Apri",
|
||||
"ui.open_ellipsis": "Apri...",
|
||||
"ui.open_ends_tip": "Non tocca altre curve",
|
||||
"ui.open_preferences": "Apri preferenze",
|
||||
"ui.open_recent": "Apri recenti",
|
||||
"ui.operation": "Operazione",
|
||||
@@ -1306,6 +1337,7 @@
|
||||
"ui.pan_zoom_orbit": "Panoramica, zoom, orbita",
|
||||
"ui.parallel": "Parallelo",
|
||||
"ui.parameters": "Parametri",
|
||||
"ui.part_size_shared": "Ricostruisce la parte, sostituendo le eventuali lavorazioni aggiunte. Si aggiornano tutte le occorrenze del componente.",
|
||||
"ui.paste": "Incolla",
|
||||
"ui.path": "Percorso",
|
||||
"ui.pattern_on_path": "Ripetizione su percorso",
|
||||
@@ -1342,6 +1374,11 @@
|
||||
"ui.rectangle": "Rettangolo",
|
||||
"ui.rectangular_pattern": "Ripetizione rettangolare",
|
||||
"ui.redo": "Ripeti",
|
||||
"ui.relink_design_changed": "L'assieme è cambiato durante la scelta del sorgente. Avvia di nuovo Ricollega sorgente.",
|
||||
"ui.relink_done": "Sorgente ricollegato per tutte le istanze.",
|
||||
"ui.relink_not_linked": "Questo componente non è collegato a un progetto sorgente.",
|
||||
"ui.relink_select_one": "Seleziona un componente collegato o una delle sue istanze.",
|
||||
"ui.relink_source_all": "Ricollega sorgente (tutte le istanze)…",
|
||||
"ui.removeFromToolbar": "Rimuovi dalla barra degli strumenti",
|
||||
"ui.removeSection": "Rimuovi sezione",
|
||||
"ui.render": "Rendering",
|
||||
@@ -1353,6 +1390,8 @@
|
||||
"ui.reset_the_settings_on_this_page": "Reimposta le impostazioni di questa pagina",
|
||||
"ui.restore": "Ripristina",
|
||||
"ui.restore_defaults": "Ripristina valori predefiniti",
|
||||
"ui.reverse_horizontal_pan_direction": "Inverti direzione della panoramica orizzontale",
|
||||
"ui.reverse_horizontal_pan_tip": "Lo scorrimento orizzontale a due dita e la rotella del pollice eseguono la panoramica nella direzione opposta",
|
||||
"ui.reverse_zoom_direction": "Inverti direzione dello zoom",
|
||||
"ui.revolve": "Rivoluzione",
|
||||
"ui.right": "Destra",
|
||||
@@ -1363,6 +1402,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Seleziona i corpi da salvare",
|
||||
"ui.save_mesh_temp_failed": "Impossibile creare {folder}: {error}",
|
||||
"ui.saved_to": "Salvato {path}",
|
||||
"ui.sbox_pin": "Fissa in cima al riquadro S",
|
||||
"ui.sbox_unpin": "Sblocca",
|
||||
"ui.scale": "Scala",
|
||||
"ui.second_rail": "Seconda guida",
|
||||
"ui.section_analysis": "Analisi in sezione",
|
||||
@@ -1406,8 +1447,34 @@
|
||||
"ui.smooth_wheel_zoom": "Zoom fluido con rotellina",
|
||||
"ui.smooth_wheel_zoom_tip": "Disattiva per applicare immediatamente ogni evento della rotellina, senza coda di interpolazione",
|
||||
"ui.snap": "Aggancio",
|
||||
"ui.space_mouse_calibrate": "Calibra…",
|
||||
"ui.space_mouse_calibrate_tip": "Muovi la manopola come chiede la finestra, così SolidCraft impara in che direzione e quanto va ogni asse di questo mouse 3D. Viene salvata per questo mouse 3D.",
|
||||
"ui.space_mouse_calibrate_title": "Calibra mouse 3D",
|
||||
"ui.space_mouse_calibrated": "Calibrato",
|
||||
"ui.space_mouse_calibration": "Calibrazione del mouse 3D",
|
||||
"ui.space_mouse_calibration_done": "Fatto. Salvala per usarla ogni volta che questo mouse 3D è collegato.",
|
||||
"ui.space_mouse_calibration_no_response": "Un asse si è mosso appena, quindi non si può calibrare. Ricomincia e muovi la manopola più a fondo ogni volta.",
|
||||
"ui.space_mouse_dead_zone": "Zona morta",
|
||||
"ui.space_mouse_dead_zone_tip": "Quanto deve muoversi la manopola prima che si muova la vista, come quota della sua corsa completa. Aumentala se la vista si sposta con la manopola a riposo.",
|
||||
"ui.space_mouse_forget": "Dimentica calibrazione",
|
||||
"ui.space_mouse_hint": "Sposta e ruota la vista insieme; gli schizzi usano solo panoramica e zoom.",
|
||||
"ui.space_mouse_navigate": "Naviga con SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "Nessun mouse 3D trovato",
|
||||
"ui.space_mouse_not_calibrated": "Non calibrato",
|
||||
"ui.space_mouse_reading": "Lettura",
|
||||
"ui.space_mouse_step_lift": "Solleva la manopola, tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_press": "Premi la manopola verso il basso, tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_pull": "Tira la manopola verso di te, tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_push": "Spingi la manopola lontano da te, tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_rest": "Togli la mano dalla manopola e lasciala ferma.",
|
||||
"ui.space_mouse_step_slide_left": "Fai scorrere la manopola a sinistra, tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_slide_right": "Fai scorrere la manopola a destra, tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_tilt_back": "Inclina la manopola all'indietro (bordo anteriore in alto), tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_tilt_forward": "Inclina la manopola in avanti (bordo anteriore in basso), tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_tilt_left": "Inclina la manopola a sinistra (bordo sinistro in basso), tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_tilt_right": "Inclina la manopola a destra (bordo destro in basso), tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Ruota la manopola in senso orario, tienila, poi lasciala.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Ruota la manopola in senso antiorario, tienila, poi lasciala.",
|
||||
"ui.spacing": "Spaziatura",
|
||||
"ui.spacing_2": "Spaziatura 2",
|
||||
"ui.sphere": "Sfera",
|
||||
@@ -1415,6 +1482,7 @@
|
||||
"ui.split_body": "Dividi corpo",
|
||||
"ui.stand_up_tip": "Dopo l'apertura di un file STEP o IGES con asse Z verticale, proponi un quarto di giro attorno a X",
|
||||
"ui.start": "Inizio",
|
||||
"ui.start_over": "Ricomincia",
|
||||
"ui.start_radius": "Raggio iniziale",
|
||||
"ui.stretch": "Stira",
|
||||
"ui.supersampling": "Sovracampionamento (SSAA)",
|
||||
@@ -1435,6 +1503,8 @@
|
||||
"ui.theme": "Tema",
|
||||
"ui.thickness": "Spessore",
|
||||
"ui.thread": "Filettatura",
|
||||
"ui.tie_ends": "Vincola",
|
||||
"ui.tie_ends_tip": "Vincola tutte le estremità che si toccano solo per posizione",
|
||||
"ui.timeline_bottom": "Sequenza temporale in basso",
|
||||
"ui.timeline_left_note": "La disposizione a sinistra mostra la barra di ripristino sopra la struttura delle parti e della scena.",
|
||||
"ui.timeline_left_sidebar": "Barra laterale sinistra",
|
||||
@@ -1457,6 +1527,7 @@
|
||||
"ui.units_of_new_designs": "Unità dei nuovi progetti",
|
||||
"ui.unlock": "Sblocca",
|
||||
"ui.unsuppress": "Riattiva",
|
||||
"ui.untied_ends_tip": "Tocca un'altra curva solo per posizione, senza vincoli",
|
||||
"ui.untitled": "Senza titolo",
|
||||
"ui.up_axis_note": "L'asse verticale nelle viste (viste degli assi, Vista iniziale, Vista normale). I progetti restano invariati.",
|
||||
"ui.updateToCurrentView": "Aggiorna alla vista corrente",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"Arc length": "円弧の長さ",
|
||||
"Area": "表面積",
|
||||
"Attachment": "アタッチメント",
|
||||
"BLEND SURFACE": "ブレンドサーフェス",
|
||||
"BOUNDARY SURFACE": "境界サーフェス",
|
||||
"BROWSER": "ブラウザ",
|
||||
"Bend Line Position": "曲げ線位置",
|
||||
@@ -79,6 +80,7 @@
|
||||
"Current orthographic view (1:1 mm)": "現在の平行投影(実寸・mm)",
|
||||
"Current perspective view": "現在の透視投影",
|
||||
"Curvature (G2)": "曲率 (G2)",
|
||||
"Curvature change (G3)": "曲率変化 (G3)",
|
||||
"Customize Toolbar": "ツールバーのカスタマイズ",
|
||||
"Delete all": "すべて削除",
|
||||
"Delete rule": "ルールを削除",
|
||||
@@ -100,6 +102,8 @@
|
||||
"Edit Profile Sketch": "プロファイルスケッチを編集",
|
||||
"Enable rule": "ルールを有効化",
|
||||
"Enabled": "有効",
|
||||
"End bulge": "終了側の膨らみ",
|
||||
"End continuity": "終了側の連続性",
|
||||
"Explode": "分解",
|
||||
"Explode Text": "テキストを分解",
|
||||
"Export Animation…": "アニメーションをエクスポート…",
|
||||
@@ -107,6 +111,7 @@
|
||||
"Export Current View as DXF…": "現在の表示をDXFでエクスポート…",
|
||||
"Export DXF…": "DXFをエクスポート…",
|
||||
"Export hidden edges as dashed lines": "点線で出力",
|
||||
"Extend to meet": "交わるまで延長",
|
||||
"Extents": "範囲",
|
||||
"Face": "面",
|
||||
"Face attachment lost": "面アタッチメントが失われました",
|
||||
@@ -121,6 +126,7 @@
|
||||
"Follow camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
|
||||
"Follow the camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
|
||||
"Font": "フォント",
|
||||
"From edges": "開始側エッジ",
|
||||
"Full Length": "全長",
|
||||
"Grid": "グリッド",
|
||||
"Height": "高さ",
|
||||
@@ -135,7 +141,9 @@
|
||||
"Interferes": "干渉あり",
|
||||
"Issue title": "Issueタイトル",
|
||||
"Joint Origin1": "ジョイント原点1",
|
||||
"Keep (points on it)": "残す部分(その上の点)",
|
||||
"Keep Tools": "ツールボディを保持",
|
||||
"Keep of the other surface": "相手のサーフェスで残す部分",
|
||||
"Later": "後で",
|
||||
"Leave it": "そのままにする",
|
||||
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "従来の面アタッチメントです。確定された面に再バインドすると正確なアタッチメントが作成されます。",
|
||||
@@ -173,6 +181,7 @@
|
||||
"Open GitHub draft": "GitHubの下書きを開く",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Rust製オープンソース・パラメトリック3D CAD。ライセンス: MIT または Apache-2.0。",
|
||||
"Other side": "反対側",
|
||||
"Other surface (mutual trim)": "相手のサーフェス(相互トリム)",
|
||||
"Outside": "外側",
|
||||
"Overall length": "全長",
|
||||
"Parameter values": "パラメータ値",
|
||||
@@ -258,12 +267,18 @@
|
||||
"Stand it up": "正立させる",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "正立させる: 取り消し可能な1つの移動フィーチャーとして、X軸周りに90度回転します。",
|
||||
"Stand this part up?": "このパーツを正立させますか?",
|
||||
"Start bulge": "開始側の膨らみ",
|
||||
"Start continuity": "開始側の連続性",
|
||||
"Step": "ステップ",
|
||||
"Steps": "ステップ数",
|
||||
"Stitch the kept pieces": "残した部分をステッチ",
|
||||
"Stitch to both bodies": "両方のボディにステッチ",
|
||||
"Study": "スタディ",
|
||||
"Style": "スタイル",
|
||||
"Suppressed": "抑制",
|
||||
"Surface": "サーフェス",
|
||||
"Swap Ends": "端点を入れ替え",
|
||||
"TRIM": "トリム",
|
||||
"Tangent (G1)": "接線 (G1)",
|
||||
"Tangent Chain": "正接チェーン",
|
||||
"Tear": "引き裂き",
|
||||
@@ -279,8 +294,10 @@
|
||||
"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.": "この面は安定したアタッチメントを提供できません。別の面を選択するか、固定平面を明示的に使用してください。",
|
||||
"To Offset": "オフセットまで",
|
||||
"To edges": "終了側エッジ",
|
||||
"Tolerance": "公差",
|
||||
"Too close": "近すぎる",
|
||||
"Trimming tool (plane, face or curves)": "トリムツール(平面、面、または曲線)",
|
||||
"Turn off section analysis before exporting the view.": "断面解析をオフにしてからエクスポートしてください。",
|
||||
"Type": "タイプ",
|
||||
"UNTRIM": "トリム解除",
|
||||
@@ -325,6 +342,7 @@
|
||||
"cmd.appearance_assign": "外観",
|
||||
"cmd.appearance_library": "外観ライブラリ",
|
||||
"cmd.appearance_list": "外観を一覧表示",
|
||||
"cmd.assembly_bom": "部品表",
|
||||
"cmd.body_rename": "ボディの名前を変更",
|
||||
"cmd.browser_group": "新規グループ",
|
||||
"cmd.browser_move": "ブラウザ項目を移動",
|
||||
@@ -346,10 +364,13 @@
|
||||
"cmd.component_insert_design": "デザインを挿入",
|
||||
"cmd.component_links": "リンクを一覧表示",
|
||||
"cmd.component_list": "コンポーネントを一覧表示",
|
||||
"cmd.component_metadata": "部品プロパティ",
|
||||
"cmd.component_metadata_set": "部品プロパティを変更",
|
||||
"cmd.component_move_bodies": "ボディをコンポーネントに移動",
|
||||
"cmd.component_move_sketches": "スケッチをコンポーネントに移動",
|
||||
"cmd.component_paste_new": "新規として貼り付け",
|
||||
"cmd.component_properties": "コンポーネントの物理的プロパティ",
|
||||
"cmd.component_relink": "参照元を再リンク",
|
||||
"cmd.component_rename": "コンポーネントの名前を変更",
|
||||
"cmd.component_revert_position": "位置を元に戻す",
|
||||
"cmd.component_unlock": "内容のロックを解除",
|
||||
@@ -401,6 +422,7 @@
|
||||
"cmd.edit_checkpoint": "チェックポイント",
|
||||
"cmd.edit_restore_checkpoint": "チェックポイントを復元",
|
||||
"cmd.engine_commands": "コマンドを一覧表示",
|
||||
"cmd.engine_describe": "コマンドの詳細を表示",
|
||||
"cmd.explode_create": "分解図",
|
||||
"cmd.explode_delete": "分解図を削除",
|
||||
"cmd.explode_list": "分解図を一覧表示",
|
||||
@@ -433,6 +455,7 @@
|
||||
"cmd.joint_drive": "ジョイントを駆動",
|
||||
"cmd.joint_edit": "ジョイントを編集",
|
||||
"cmd.joint_gear": "ギア",
|
||||
"cmd.joint_inspect": "ジョイントを検査",
|
||||
"cmd.joint_limits": "ジョイントの制限",
|
||||
"cmd.joint_list": "ジョイントを一覧表示",
|
||||
"cmd.joint_motion_link": "モーション リンク",
|
||||
@@ -442,6 +465,7 @@
|
||||
"cmd.joint_rigid_group": "リジッド グループ",
|
||||
"cmd.joint_screw": "ねじ",
|
||||
"cmd.joint_solve": "ジョイントを解決",
|
||||
"cmd.joint_sweep": "ジョイントをスイープ",
|
||||
"cmd.library_apply": "ローカルライブラリ項目を適用",
|
||||
"cmd.library_export": "デザインライブラリを書き出す",
|
||||
"cmd.library_inspect": "ローカルライブラリを確認",
|
||||
@@ -590,6 +614,7 @@
|
||||
"cmd.sketch_spline_fit_point": "通過点スプライン",
|
||||
"cmd.sketch_spun_profile": "回転プロファイル",
|
||||
"cmd.sketch_toggle_driven": "従動寸法の切り替え",
|
||||
"cmd.sketch_trim_path": "パスに沿ってトリム",
|
||||
"cmd.solid_align": "整列",
|
||||
"cmd.solid_boundary_fill": "境界フィル",
|
||||
"cmd.solid_bounding_solid": "バウンディング ソリッド",
|
||||
@@ -632,8 +657,10 @@
|
||||
"cmd.timeline_roll_to": "履歴マーカーを移動",
|
||||
"cmd.timeline_rollback": "タイムライン マーカーを移動",
|
||||
"cmd.timeline_suppress": "フィーチャーを抑制",
|
||||
"cmd.view_ambient_occlusion": "アンビエントオクルージョン",
|
||||
"cmd.view_apply": "名前付きビューを復元",
|
||||
"cmd.view_delete": "名前付きビューを削除",
|
||||
"cmd.view_graphics_preset": "グラフィックスプリセット",
|
||||
"cmd.view_list": "名前付きビューを一覧表示",
|
||||
"cmd.view_navigate": "ビューナビゲーション",
|
||||
"cmd.view_rename": "名前付きビューの名前を変更",
|
||||
@@ -716,6 +743,15 @@
|
||||
"joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。",
|
||||
"joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。",
|
||||
"joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。",
|
||||
"messages.ago_h": "{n} 時間前",
|
||||
"messages.ago_min": "{n} 分前",
|
||||
"messages.ago_s": "{n} 秒前",
|
||||
"messages.chip_hint": "クリックするとこのセッションのすべてのメッセージを表示します",
|
||||
"messages.clear": "クリア",
|
||||
"messages.copy_all": "すべてコピー",
|
||||
"messages.menu": "メッセージ…",
|
||||
"messages.none": "まだメッセージはありません。",
|
||||
"messages.title": "メッセージ",
|
||||
"motion.checked_to": "ステップ {step} までしか確認していません: 確認の計算量の上限に達しました",
|
||||
"motion.collision": "{a} · {b}: ステップ {steps}",
|
||||
"motion.duration_hint": "秒 (空欄: ステップごと)",
|
||||
@@ -857,7 +893,7 @@
|
||||
"ui.agent_chat_approval": "エージェントにこの操作を許可しますか?",
|
||||
"ui.agent_chat_attach_view": "ビューを添付",
|
||||
"ui.agent_chat_attach_view_tip": "現在のビューの画像をメッセージと一緒に送ります",
|
||||
"ui.agent_chat_attachable": "画像・テキスト・CAD ファイル",
|
||||
"ui.agent_chat_attachable": "画像・PDF・テキスト・CAD ファイル",
|
||||
"ui.agent_chat_auth_agent": "{name} でサインイン",
|
||||
"ui.agent_chat_auth_env": "環境変数 {vars} を設定して SolidCraft を再起動してください。",
|
||||
"ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。SolidCraft がパスワードやキーを見ることはありません。",
|
||||
@@ -889,7 +925,6 @@
|
||||
"ui.agent_chat_file_error": "{name}: {error}",
|
||||
"ui.agent_chat_file_heic": "{name}: HEIC 画像はここでは読めません。先に JPEG か PNG に書き出してください",
|
||||
"ui.agent_chat_file_importing": "デザインにインポート中…",
|
||||
"ui.agent_chat_file_kinds": "{name}: 添付できるのは画像 (PNG、JPEG、GIF、WebP、BMP、TIFF)、テキストファイル (SVG も)、CAD ファイル (STEP、IGES、FreeCAD、Rhino、STL、3MF、OBJ、glTF) です",
|
||||
"ui.agent_chat_file_loading": "ファイルを読み込み中…",
|
||||
"ui.agent_chat_file_not_a_file": "{name}: ファイルではありません",
|
||||
"ui.agent_chat_file_not_a_picture": "{name}: 画像ではありません",
|
||||
@@ -1036,6 +1071,8 @@
|
||||
"ui.ai_toolbar_button_note": "ツールバー右端の AI ボタン。Ctrl+Shift+A と表示メニューからも引き続きパネルを開けます。",
|
||||
"ui.ai_undo_tip": "このメッセージでエージェントが行った変更を、1回でまとめて元に戻します",
|
||||
"ui.all": "すべて",
|
||||
"ui.ambient_occlusion": "アンビエントオクルージョン",
|
||||
"ui.ambient_occlusion_help": "不透明な面が接する部分に柔らかい陰影を加えます。GPUビューポートが必要です。",
|
||||
"ui.analysis": "解析",
|
||||
"ui.angle": "角度",
|
||||
"ui.angle_decimals": "角度の小数点以下の桁数",
|
||||
@@ -1050,6 +1087,7 @@
|
||||
"ui.autosave_every": "自動保存の間隔",
|
||||
"ui.axes": "軸",
|
||||
"ui.axis": "軸",
|
||||
"ui.back": "戻る",
|
||||
"ui.base_point": "基点",
|
||||
"ui.blocked": "到達不可",
|
||||
"ui.bodies": "ボディ",
|
||||
@@ -1196,6 +1234,11 @@
|
||||
"ui.fully_constrained": "完全拘束",
|
||||
"ui.general": "一般",
|
||||
"ui.graphics": "グラフィックス",
|
||||
"ui.graphics_custom": "カスタム",
|
||||
"ui.graphics_performance": "パフォーマンス",
|
||||
"ui.graphics_preset": "グラフィックスプリセット",
|
||||
"ui.graphics_preset_help": "品質:アンビエントオクルージョン、細かい表示品質、適応型2×スーパーサンプリング。パフォーマンス:AOなし、標準の表示品質、スーパーサンプリングなし。手動変更はカスタムと表示されます。MSAAは変更されません。",
|
||||
"ui.graphics_quality": "品質",
|
||||
"ui.grid_follows_view": "グリッドをビューに追従させる",
|
||||
"ui.grid_follows_view_hint": "側面ビューでは、グリッドを地面ではなく、そのビューに向いた垂直平面に表示します",
|
||||
"ui.ground_shadow": "地面の影",
|
||||
@@ -1298,6 +1341,8 @@
|
||||
"ui.loft": "ロフト",
|
||||
"ui.look_at": "正面視",
|
||||
"ui.look_at_anim_desc": "軸ビュー、正面視、ホーム、全体表示",
|
||||
"ui.manipulator_free_alt": "マニピュレータのフリードラッグを Alt/Option でも有効",
|
||||
"ui.manipulator_free_alt_tip": "オン: 押し出し/ギズモ矢印で Alt/Option または Ctrl/Cmd が値スナップを無効にします。オフ: Ctrl/Cmd のみ。",
|
||||
"ui.manufacture": "製造",
|
||||
"ui.material_review.axes": "材料ローカル軸。ボディの方向は割り当てません。",
|
||||
"ui.material_review.claims": "出典とライセンスの申告は未確認です。欠落値は不明のままです。",
|
||||
@@ -1337,6 +1382,7 @@
|
||||
"ui.newSketchGroup": "新しいスケッチグループ",
|
||||
"ui.new_body": "新規ボディ",
|
||||
"ui.new_design": "新規デザイン",
|
||||
"ui.no_geometry_yet": "ジオメトリはまだありません",
|
||||
"ui.no_slicer_found": "スライサーが見つかりません",
|
||||
"ui.normal": "標準",
|
||||
"ui.normalConstruction": "通常 / コンストラクション",
|
||||
@@ -1364,6 +1410,7 @@
|
||||
"ui.pan_zoom_orbit": "パン、ズーム、オービット",
|
||||
"ui.parallel": "平行",
|
||||
"ui.parameters": "パラメータ",
|
||||
"ui.part_size_shared": "パーツを再構築し、追加したフィーチャを置き換えます。このコンポーネントのすべての配置が更新されます。",
|
||||
"ui.paste": "貼り付け",
|
||||
"ui.path": "パス",
|
||||
"ui.pattern_on_path": "パス上のパターン",
|
||||
@@ -1375,6 +1422,12 @@
|
||||
"ui.placement": "配置",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "傾斜平面",
|
||||
"ui.plugins": "プラグイン",
|
||||
"ui.plugins_desktop": "プラグインはデスクトップ版で使えます。",
|
||||
"ui.plugins_empty": "プラグインは入っていません。",
|
||||
"ui.plugins_note": "プラグインは {path} の中のフォルダで、plugin.json を置きます。SolidCraft はそのファイルを読むだけで、フォルダのコードは実行しません。オフにしてもインストールは残ります。",
|
||||
"ui.plugins_skipped": "{count} 個のフォルダを読めませんでした。",
|
||||
"ui.plugins_version": "{version}",
|
||||
"ui.point": "点",
|
||||
"ui.point_under_the_cursor": "カーソル下の点",
|
||||
"ui.points": "点",
|
||||
@@ -1400,6 +1453,11 @@
|
||||
"ui.rectangle": "長方形",
|
||||
"ui.rectangular_pattern": "矩形パターン",
|
||||
"ui.redo": "やり直す",
|
||||
"ui.relink_design_changed": "参照元の選択中にアセンブリが変更されました。再リンクをやり直してください。",
|
||||
"ui.relink_done": "すべてのインスタンスの参照元を再リンクしました。",
|
||||
"ui.relink_not_linked": "このコンポーネントは参照元の設計にリンクされていません。",
|
||||
"ui.relink_select_one": "リンクされたコンポーネントまたはそのインスタンスを1つ選択してください。",
|
||||
"ui.relink_source_all": "参照元を再リンク(すべてのインスタンス)…",
|
||||
"ui.removeFromToolbar": "ツールバーから削除",
|
||||
"ui.removeSection": "セクションを解除",
|
||||
"ui.render": "レンダリング",
|
||||
@@ -1423,6 +1481,8 @@
|
||||
"ui.save_mesh_pick_bodies": "保存するボディを選択してください",
|
||||
"ui.save_mesh_temp_failed": "{folder} を作成できません: {error}",
|
||||
"ui.saved_to": "保存しました: {path}",
|
||||
"ui.sbox_pin": "Sボックスの上部に固定",
|
||||
"ui.sbox_unpin": "固定を解除",
|
||||
"ui.scale": "尺度",
|
||||
"ui.second_rail": "第2レール",
|
||||
"ui.section_analysis": "断面解析",
|
||||
@@ -1431,6 +1491,9 @@
|
||||
"ui.selectTangentChain": "接線チェーンを選択",
|
||||
"ui.select_other_hint": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。",
|
||||
"ui.selection": "選択",
|
||||
"ui.selection_modifiers": "選択修飾キー",
|
||||
"ui.selection_shift_add_only": "Shift+クリックで追加のみ(解除なし)",
|
||||
"ui.selection_shift_add_only_tip": "オン: Shift は追加、Ctrl/Cmd はトグル。オフ(既定): Shift と Ctrl/Cmd の両方がトグル。",
|
||||
"ui.send": "送信",
|
||||
"ui.send_to_slicer": "スライサーに送る",
|
||||
"ui.sent_to_slicer": "{slicer} に送信しました",
|
||||
@@ -1455,6 +1518,7 @@
|
||||
"ui.sketch": "スケッチ",
|
||||
"ui.sketch_dimension": "スケッチ寸法",
|
||||
"ui.sketch_grid": "スケッチ グリッド",
|
||||
"ui.sketch_modifiers": "スケッチ修飾キー",
|
||||
"ui.sketch_scale": "スケッチ尺度",
|
||||
"ui.sketch_status": "スケッチの状態",
|
||||
"ui.sketches": "スケッチ",
|
||||
@@ -1466,8 +1530,34 @@
|
||||
"ui.smooth_wheel_zoom": "滑らかなホイールズーム",
|
||||
"ui.smooth_wheel_zoom_tip": "オフにすると、ホイール操作のたびにズームが即座に反映され、滑らかな減速効果は適用されません。",
|
||||
"ui.snap": "スナップ",
|
||||
"ui.space_mouse_calibrate": "キャリブレーション…",
|
||||
"ui.space_mouse_calibrate_tip": "ウィンドウの指示どおりにキャップを動かすと、この3Dマウスの各軸の向きと移動量をSolidCraftが学習します。この3Dマウス用に保存されます。",
|
||||
"ui.space_mouse_calibrate_title": "3Dマウスのキャリブレーション",
|
||||
"ui.space_mouse_calibrated": "キャリブレーション済み",
|
||||
"ui.space_mouse_calibration": "3Dマウスのキャリブレーション",
|
||||
"ui.space_mouse_calibration_done": "完了しました。保存すると、この3Dマウスを接続したときに毎回使われます。",
|
||||
"ui.space_mouse_calibration_no_response": "ほとんど動かなかった軸があるため、キャリブレーションできません。最初からやり直し、毎回キャップをもっと大きく動かしてください。",
|
||||
"ui.space_mouse_dead_zone": "デッドゾーン",
|
||||
"ui.space_mouse_dead_zone_tip": "視点が動き出すまでにキャップを動かす量(全ストロークに対する割合)。キャップに触れていないのに視点が動く場合は大きくしてください。",
|
||||
"ui.space_mouse_forget": "キャリブレーションを削除",
|
||||
"ui.space_mouse_hint": "視点の移動と回転を同時に行います(スケッチ中はパンとズームのみ)",
|
||||
"ui.space_mouse_navigate": "SpaceNavigator / SpaceMouseで視点を操作",
|
||||
"ui.space_mouse_none": "3Dマウスが見つかりません",
|
||||
"ui.space_mouse_not_calibrated": "未キャリブレーション",
|
||||
"ui.space_mouse_reading": "読み取り値",
|
||||
"ui.space_mouse_step_lift": "キャップを持ち上げて保持し、離してください。",
|
||||
"ui.space_mouse_step_press": "キャップを押し下げて保持し、離してください。",
|
||||
"ui.space_mouse_step_pull": "キャップを手前に引いて保持し、離してください。",
|
||||
"ui.space_mouse_step_push": "キャップを奥に押して保持し、離してください。",
|
||||
"ui.space_mouse_step_rest": "キャップから手を離し、動かさないでください。",
|
||||
"ui.space_mouse_step_slide_left": "キャップを左にスライドして保持し、離してください。",
|
||||
"ui.space_mouse_step_slide_right": "キャップを右にスライドして保持し、離してください。",
|
||||
"ui.space_mouse_step_tilt_back": "キャップを後ろに傾け(手前の縁を上げて)保持し、離してください。",
|
||||
"ui.space_mouse_step_tilt_forward": "キャップを前に傾け(手前の縁を下げて)保持し、離してください。",
|
||||
"ui.space_mouse_step_tilt_left": "キャップを左に傾け(左の縁を下げて)保持し、離してください。",
|
||||
"ui.space_mouse_step_tilt_right": "キャップを右に傾け(右の縁を下げて)保持し、離してください。",
|
||||
"ui.space_mouse_step_twist_clockwise": "キャップを時計回りにひねって保持し、離してください。",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "キャップを反時計回りにひねって保持し、離してください。",
|
||||
"ui.spacing": "間隔",
|
||||
"ui.spacing_2": "間隔 2",
|
||||
"ui.sphere": "球",
|
||||
@@ -1475,12 +1565,15 @@
|
||||
"ui.split_body": "ボディを分割",
|
||||
"ui.stand_up_tip": "Z軸上向きで開いたSTEPまたはIGESファイルに対し、X軸周りに90度回転させるか確認します",
|
||||
"ui.start": "開始",
|
||||
"ui.start_over": "最初からやり直す",
|
||||
"ui.start_radius": "開始半径",
|
||||
"ui.stretch": "伸縮",
|
||||
"ui.supersampling": "スーパーサンプリング (SSAA)",
|
||||
"ui.supersampling_adaptive": "ビューが静止しているときのみ",
|
||||
"ui.supersampling_off": "オフ (1×)",
|
||||
"ui.suppress": "抑制",
|
||||
"ui.suppress_auto_snap": "作図・ドラッグ中の自動スナップを抑制",
|
||||
"ui.suppress_auto_snap_note": "既定: Ctrl/Cmd または Alt/Option のどれでもスケッチスナップを抑制します。単一キーに絞ることもできます。",
|
||||
"ui.sweep": "スイープ",
|
||||
"ui.sweep_follow_direction": "方向に従う",
|
||||
"ui.sweep_keep_as_drawn": "描いた向きを保持",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"Current orthographic view (1:1 mm)": "현재 직교 투영 뷰 (1:1 mm)",
|
||||
"Current perspective view": "현재 원근 투영 뷰",
|
||||
"Curvature (G2)": "곡률 (G2)",
|
||||
"Curvature change (G3)": "곡률 변화 (G3)",
|
||||
"Direction": "방향",
|
||||
"Do not export hidden edges": "숨겨진 모서리 내보내지 않기",
|
||||
"Don't ask again": "다시 묻지 않기",
|
||||
@@ -39,16 +40,20 @@
|
||||
"Export Current View as DXF…": "현재 뷰를 DXF로 내보내기…",
|
||||
"Export DXF…": "DXF 내보내기…",
|
||||
"Export hidden edges as dashed lines": "숨겨진 모서리를 파선으로 내보내기",
|
||||
"Extend to meet": "만날 때까지 연장",
|
||||
"Face": "면",
|
||||
"Faces": "면",
|
||||
"Find in Browser": "브라우저에서 찾기",
|
||||
"Flat": "플랫",
|
||||
"From edges": "시작 모서리",
|
||||
"Hidden edges": "숨겨진 모서리",
|
||||
"Holes": "구멍",
|
||||
"Holes (edges on them)": "구멍 (구멍의 모서리)",
|
||||
"In place (surfaces)": "제자리에서 (서피스)",
|
||||
"In plane": "평면 내",
|
||||
"Inside": "안쪽",
|
||||
"Keep (points on it)": "유지할 부분(그 위의 점)",
|
||||
"Keep of the other surface": "다른 곡면에서 유지할 부분",
|
||||
"Length": "길이",
|
||||
"Max": "최대",
|
||||
"Min": "최소",
|
||||
@@ -58,6 +63,7 @@
|
||||
"None": "없음",
|
||||
"Normal to sketch": "스케치에 수직",
|
||||
"Notch": "노치",
|
||||
"Other surface (mutual trim)": "다른 곡면(상호 트림)",
|
||||
"Outside": "바깥쪽",
|
||||
"Overall length": "전체 길이",
|
||||
"Pass through": "통과점",
|
||||
@@ -87,12 +93,18 @@
|
||||
"Sketch geometry and profiles": "스케치 기하 및 프로파일",
|
||||
"Sketch points": "스케치 점",
|
||||
"Slot width": "슬롯 폭",
|
||||
"Stitch the kept pieces": "유지한 부분 재봉",
|
||||
"Stitch to both bodies": "두 바디에 재봉",
|
||||
"Study": "연구",
|
||||
"Surface": "곡면",
|
||||
"TRIM": "트림",
|
||||
"Tangent (G1)": "접선 (G1)",
|
||||
"Tear": "찢김",
|
||||
"Thickness Outside": "바깥쪽 두께",
|
||||
"Thin": "얇은 벽",
|
||||
"Thin Extrude": "얇은 벽 돌출",
|
||||
"To edges": "끝 모서리",
|
||||
"Trimming tool (plane, face or curves)": "트림 도구(평면, 면 또는 곡선)",
|
||||
"Turn off section analysis before exporting the view.": "뷰를 내보내기 전에 단면 분석을 끄세요.",
|
||||
"Type": "유형",
|
||||
"UNTRIM": "트림 해제",
|
||||
@@ -238,6 +250,7 @@
|
||||
"cmd.joint_rigid_group": "강체 그룹",
|
||||
"cmd.joint_screw": "나사",
|
||||
"cmd.joint_solve": "관절 해석",
|
||||
"cmd.joint_sweep": "관절 스윕",
|
||||
"cmd.library_apply": "로컬 라이브러리 항목 적용",
|
||||
"cmd.library_export": "설계 라이브러리 내보내기",
|
||||
"cmd.library_inspect": "로컬 라이브러리 확인",
|
||||
@@ -425,8 +438,10 @@
|
||||
"cmd.timeline_roll_to": "이력 마커 이동",
|
||||
"cmd.timeline_rollback": "타임라인 마커 이동",
|
||||
"cmd.timeline_suppress": "특성 비활성화",
|
||||
"cmd.view_ambient_occlusion": "앰비언트 오클루전",
|
||||
"cmd.view_apply": "이름 있는 보기 복원",
|
||||
"cmd.view_delete": "이름 있는 보기 삭제",
|
||||
"cmd.view_graphics_preset": "그래픽 프리셋",
|
||||
"cmd.view_list": "이름 있는 보기 목록",
|
||||
"cmd.view_navigate": "보기 탐색",
|
||||
"cmd.view_rename": "이름 있는 보기 이름 변경",
|
||||
@@ -756,6 +771,8 @@
|
||||
"ui.ai_toolbar_button_note": "도구 모음 오른쪽 끝의 AI 버튼. Ctrl+Shift+A와 보기 메뉴로도 패널을 열 수 있습니다.",
|
||||
"ui.ai_undo_tip": "이 메시지에서 에이전트가 바꾼 모든 것을 한 번에 실행 취소",
|
||||
"ui.all": "모두",
|
||||
"ui.ambient_occlusion": "앰비언트 오클루전",
|
||||
"ui.ambient_occlusion_help": "불투명한 표면이 만나는 곳에 부드러운 음영을 추가합니다. GPU 뷰포트가 필요합니다.",
|
||||
"ui.analysis": "분석",
|
||||
"ui.angle": "각도",
|
||||
"ui.angle_decimals": "각도 소수점 자릿수",
|
||||
@@ -770,6 +787,7 @@
|
||||
"ui.autosave_every": "자동 저장 간격",
|
||||
"ui.axes": "축",
|
||||
"ui.axis": "축",
|
||||
"ui.back": "뒤로",
|
||||
"ui.base_point": "기준점",
|
||||
"ui.blocked": "막힘",
|
||||
"ui.bodies": "바디",
|
||||
@@ -910,6 +928,11 @@
|
||||
"ui.fully_constrained": "완전 구속",
|
||||
"ui.general": "일반",
|
||||
"ui.graphics": "그래픽",
|
||||
"ui.graphics_custom": "사용자 지정",
|
||||
"ui.graphics_performance": "성능",
|
||||
"ui.graphics_preset": "그래픽 프리셋",
|
||||
"ui.graphics_preset_help": "품질: 앰비언트 오클루전, 세밀한 표시 품질, 적응형 2× 슈퍼샘플링. 성능: 앰비언트 오클루전 끄기, 보통 표시 품질, 슈퍼샘플링 끄기. 수동 변경 시 사용자 지정으로 표시됩니다. MSAA는 유지됩니다.",
|
||||
"ui.graphics_quality": "품질",
|
||||
"ui.groupSelected": "선택 항목 그룹화",
|
||||
"ui.heading_assemble": "조립",
|
||||
"ui.heading_box": "박스",
|
||||
@@ -1116,6 +1139,8 @@
|
||||
"ui.save_mesh_pick_bodies": "저장할 바디를 선택하세요",
|
||||
"ui.save_mesh_temp_failed": "{folder}을(를) 만들 수 없습니다: {error}",
|
||||
"ui.saved_to": "저장됨: {path}",
|
||||
"ui.sbox_pin": "S 상자 맨 위에 고정",
|
||||
"ui.sbox_unpin": "고정 해제",
|
||||
"ui.scale": "축척",
|
||||
"ui.section_analysis": "단면 분석",
|
||||
"ui.select": "선택",
|
||||
@@ -1155,12 +1180,39 @@
|
||||
"ui.slot": "슬롯",
|
||||
"ui.smooth": "부드럽게",
|
||||
"ui.snap": "스냅",
|
||||
"ui.space_mouse_calibrate": "보정…",
|
||||
"ui.space_mouse_calibrate_tip": "창의 안내대로 캡을 움직이면 SolidCraft가 이 3D 마우스의 각 축이 어느 방향으로 얼마나 움직이는지 학습합니다. 이 3D 마우스에 저장됩니다.",
|
||||
"ui.space_mouse_calibrate_title": "3D 마우스 보정",
|
||||
"ui.space_mouse_calibrated": "보정됨",
|
||||
"ui.space_mouse_calibration": "3D 마우스 보정",
|
||||
"ui.space_mouse_calibration_done": "완료했습니다. 저장하면 이 3D 마우스를 연결할 때마다 사용됩니다.",
|
||||
"ui.space_mouse_calibration_no_response": "거의 움직이지 않은 축이 있어 보정할 수 없습니다. 처음부터 다시 하고 매번 캡을 더 멀리 움직이세요.",
|
||||
"ui.space_mouse_dead_zone": "데드존",
|
||||
"ui.space_mouse_dead_zone_tip": "보기가 움직이기 전에 캡이 움직여야 하는 정도로, 전체 이동 범위에 대한 비율입니다. 캡을 건드리지 않았는데 보기가 움직이면 높이세요.",
|
||||
"ui.space_mouse_forget": "보정 삭제",
|
||||
"ui.space_mouse_none": "3D 마우스를 찾을 수 없음",
|
||||
"ui.space_mouse_not_calibrated": "보정 안 됨",
|
||||
"ui.space_mouse_reading": "읽은 값",
|
||||
"ui.space_mouse_step_lift": "캡을 위로 들어 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_press": "캡을 아래로 눌러 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_pull": "캡을 몸 쪽으로 당겨 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_push": "캡을 앞쪽으로 밀어 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_rest": "캡에서 손을 떼고 가만히 두세요.",
|
||||
"ui.space_mouse_step_slide_left": "캡을 왼쪽으로 밀어 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_slide_right": "캡을 오른쪽으로 밀어 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_tilt_back": "캡을 뒤로 기울여(앞쪽 가장자리를 위로) 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_tilt_forward": "캡을 앞으로 기울여(앞쪽 가장자리를 아래로) 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_tilt_left": "캡을 왼쪽으로 기울여(왼쪽 가장자리를 아래로) 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_tilt_right": "캡을 오른쪽으로 기울여(오른쪽 가장자리를 아래로) 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_twist_clockwise": "캡을 시계 방향으로 돌려 유지한 다음 놓으세요.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "캡을 시계 반대 방향으로 돌려 유지한 다음 놓으세요.",
|
||||
"ui.spacing": "간격",
|
||||
"ui.spacing_2": "간격 2",
|
||||
"ui.sphere": "구",
|
||||
"ui.spline": "스플라인",
|
||||
"ui.split_body": "바디 분할",
|
||||
"ui.start": "시작",
|
||||
"ui.start_over": "처음부터 다시",
|
||||
"ui.start_radius": "시작 반지름",
|
||||
"ui.supersampling": "슈퍼샘플링 (SSAA)",
|
||||
"ui.supersampling_adaptive": "뷰가 멈춰 있을 때만",
|
||||
|
||||
@@ -1384,6 +1384,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Zaznacz bryły do zapisania",
|
||||
"ui.save_mesh_temp_failed": "Nie udało się utworzyć {folder}: {error}",
|
||||
"ui.saved_to": "Zapisano {path}",
|
||||
"ui.sbox_pin": "Przypnij na górze okna S",
|
||||
"ui.sbox_unpin": "Odepnij",
|
||||
"ui.scale": "Skala",
|
||||
"ui.second_rail": "Druga prowadnica",
|
||||
"ui.section_analysis": "Analiza przekroju",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"Current orthographic view (1:1 mm)": "Vista ortográfica atual (1:1 mm)",
|
||||
"Current perspective view": "Vista em perspectiva atual",
|
||||
"Curvature (G2)": "Curvatura (G2)",
|
||||
"Curvature change (G3)": "Variação de curvatura (G3)",
|
||||
"Customize Toolbar": "Personalizar barra de ferramentas",
|
||||
"Delete all": "Excluir tudo",
|
||||
"Delete rule": "Excluir regra",
|
||||
@@ -98,6 +99,7 @@
|
||||
"Export Current View as DXF…": "Exportar vista atual como DXF…",
|
||||
"Export DXF…": "Exportar DXF…",
|
||||
"Export hidden edges as dashed lines": "Exportar arestas ocultas como linhas tracejadas",
|
||||
"Extend to meet": "Estender até encontrar",
|
||||
"Extents": "Extensões",
|
||||
"Face": "Face",
|
||||
"Face attachment lost": "Fixação à face perdida",
|
||||
@@ -112,6 +114,7 @@
|
||||
"Follow camera (Look At sketches)": "Seguir a câmera (Olhar para os esboços)",
|
||||
"Follow the camera (Look At sketches)": "Seguir a câmera (Olhar para os esboços)",
|
||||
"Font": "Fonte",
|
||||
"From edges": "Arestas de origem",
|
||||
"Full Length": "Comprimento total",
|
||||
"Grid": "Grade",
|
||||
"Height": "Altura",
|
||||
@@ -126,7 +129,9 @@
|
||||
"Interferes": "Interfere",
|
||||
"Issue title": "Título da issue",
|
||||
"Joint Origin1": "Origem da junta1",
|
||||
"Keep (points on it)": "Manter (pontos sobre ela)",
|
||||
"Keep Tools": "Manter ferramentas",
|
||||
"Keep of the other surface": "Manter da outra superfície",
|
||||
"Later": "Depois",
|
||||
"Leave it": "Deixar assim",
|
||||
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Fixação de face antiga. Vincular novamente a uma face certificada cria uma fixação exata.",
|
||||
@@ -162,6 +167,7 @@
|
||||
"Open GitHub draft": "Abrir rascunho no GitHub",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "CAD 3D paramétrico de código aberto em Rust. Licenciado sob MIT OU Apache-2.0.",
|
||||
"Other side": "Outro lado",
|
||||
"Other surface (mutual trim)": "Outra superfície (aparo mútuo)",
|
||||
"Outside": "Fora",
|
||||
"Overall length": "Comprimento total",
|
||||
"Parameter values": "Valores dos parâmetros",
|
||||
@@ -245,9 +251,13 @@
|
||||
"Stand this part up?": "Colocar esta peça em pé?",
|
||||
"Step": "Passo",
|
||||
"Steps": "Passos",
|
||||
"Stitch the kept pieces": "Costurar as partes mantidas",
|
||||
"Stitch to both bodies": "Costurar aos dois corpos",
|
||||
"Style": "Estilo",
|
||||
"Suppressed": "Suprimido",
|
||||
"Surface": "Superfície",
|
||||
"Swap Ends": "Trocar extremidades",
|
||||
"TRIM": "APARAR",
|
||||
"Tangent (G1)": "Tangente (G1)",
|
||||
"Tangent Chain": "Cadeia tangente",
|
||||
"Tear": "Rasgo",
|
||||
@@ -263,7 +273,9 @@
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "Este filho é compartilhado. Mudanças de posição se aplicam a todas as cópias do pai.",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta face não pode fornecer uma fixação duradoura. Escolha outra face ou use explicitamente um plano fixo.",
|
||||
"To Offset": "Deslocamento final",
|
||||
"To edges": "Arestas de destino",
|
||||
"Tolerance": "Tolerância",
|
||||
"Trimming tool (plane, face or curves)": "Ferramenta de aparo (plano, face ou curvas)",
|
||||
"Turn off section analysis before exporting the view.": "Desative a análise de seção antes de exportar a vista.",
|
||||
"Type": "Tipo",
|
||||
"UNTRIM": "DESAPARAR",
|
||||
@@ -956,6 +968,7 @@
|
||||
"ui.autosave_every": "Salvar automaticamente a cada",
|
||||
"ui.axes": "Eixos",
|
||||
"ui.axis": "Eixo",
|
||||
"ui.back": "Voltar",
|
||||
"ui.base_point": "Ponto base",
|
||||
"ui.blocked": "Bloqueado",
|
||||
"ui.bodies": "Corpos",
|
||||
@@ -1317,6 +1330,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Selecione os corpos a salvar",
|
||||
"ui.save_mesh_temp_failed": "Não foi possível criar {folder}: {error}",
|
||||
"ui.saved_to": "Salvo {path}",
|
||||
"ui.sbox_pin": "Fixar no topo da caixa S",
|
||||
"ui.sbox_unpin": "Desafixar",
|
||||
"ui.scale": "Escala",
|
||||
"ui.section_analysis": "Análise de seção",
|
||||
"ui.select": "Selecionar",
|
||||
@@ -1359,8 +1374,34 @@
|
||||
"ui.smooth_wheel_zoom": "Zoom suave com a roda",
|
||||
"ui.smooth_wheel_zoom_tip": "Desative para aplicar cada evento da roda na hora, sem rastro de suavização",
|
||||
"ui.snap": "Encaixe",
|
||||
"ui.space_mouse_calibrate": "Calibrar…",
|
||||
"ui.space_mouse_calibrate_tip": "Mova a tampa como a janela pedir, para que o SolidCraft aprenda para onde e até onde vai cada eixo deste mouse 3D. Salvo para este mouse 3D.",
|
||||
"ui.space_mouse_calibrate_title": "Calibrar mouse 3D",
|
||||
"ui.space_mouse_calibrated": "Calibrado",
|
||||
"ui.space_mouse_calibration": "Calibração do mouse 3D",
|
||||
"ui.space_mouse_calibration_done": "Pronto. Salve para usá-la sempre que este mouse 3D estiver conectado.",
|
||||
"ui.space_mouse_calibration_no_response": "Um eixo quase não se moveu, então não dá para calibrá-lo. Recomece e mova a tampa mais longe a cada vez.",
|
||||
"ui.space_mouse_dead_zone": "Zona morta",
|
||||
"ui.space_mouse_dead_zone_tip": "Quanto a tampa precisa se mover antes de a vista se mover, como fração do curso completo. Aumente se a vista se mexer com a tampa parada.",
|
||||
"ui.space_mouse_forget": "Esquecer calibração",
|
||||
"ui.space_mouse_hint": "Move e gira a vista juntos; esboços usam apenas deslocamento e zoom.",
|
||||
"ui.space_mouse_navigate": "Navegar com SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "Nenhum mouse 3D encontrado",
|
||||
"ui.space_mouse_not_calibrated": "Não calibrado",
|
||||
"ui.space_mouse_reading": "Leitura",
|
||||
"ui.space_mouse_step_lift": "Levante a tampa, segure e solte.",
|
||||
"ui.space_mouse_step_press": "Pressione a tampa para baixo, segure e solte.",
|
||||
"ui.space_mouse_step_pull": "Puxe a tampa na sua direção, segure e solte.",
|
||||
"ui.space_mouse_step_push": "Empurre a tampa para longe de você, segure e solte.",
|
||||
"ui.space_mouse_step_rest": "Tire a mão da tampa e deixe-a parada.",
|
||||
"ui.space_mouse_step_slide_left": "Deslize a tampa para a esquerda, segure e solte.",
|
||||
"ui.space_mouse_step_slide_right": "Deslize a tampa para a direita, segure e solte.",
|
||||
"ui.space_mouse_step_tilt_back": "Incline a tampa para trás (borda da frente para cima), segure e solte.",
|
||||
"ui.space_mouse_step_tilt_forward": "Incline a tampa para a frente (borda da frente para baixo), segure e solte.",
|
||||
"ui.space_mouse_step_tilt_left": "Incline a tampa para a esquerda (borda esquerda para baixo), segure e solte.",
|
||||
"ui.space_mouse_step_tilt_right": "Incline a tampa para a direita (borda direita para baixo), segure e solte.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Gire a tampa no sentido horário, segure e solte.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Gire a tampa no sentido anti-horário, segure e solte.",
|
||||
"ui.spacing": "Espaçamento",
|
||||
"ui.spacing_2": "Espaçamento 2",
|
||||
"ui.sphere": "Esfera",
|
||||
@@ -1368,6 +1409,7 @@
|
||||
"ui.split_body": "Dividir corpo",
|
||||
"ui.stand_up_tip": "Depois que um arquivo STEP ou IGES abrir com Z para cima, oferecer um quarto de volta em torno de X",
|
||||
"ui.start": "Início",
|
||||
"ui.start_over": "Recomeçar",
|
||||
"ui.start_radius": "Raio inicial",
|
||||
"ui.supersampling": "Supersampling (SSAA)",
|
||||
"ui.supersampling_adaptive": "Só quando a vista está parada",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"Create Issue — review draft": "Создать Issue — проверить черновик",
|
||||
"Cube": "Куб",
|
||||
"Curvature (G2)": "Кривизна (G2)",
|
||||
"Curvature change (G3)": "Изменение кривизны (G3)",
|
||||
"Customize Toolbar": "Настроить панель инструментов",
|
||||
"Delete all": "Удалить все",
|
||||
"Delete rule": "Удалить правило",
|
||||
@@ -88,6 +89,7 @@
|
||||
"Enabled": "Включено",
|
||||
"Explode": "Разобрать",
|
||||
"Export Animation…": "Экспортировать анимацию…",
|
||||
"Extend to meet": "Продлить до пересечения",
|
||||
"Extents": "Пределы",
|
||||
"Face attachment lost": "Привязка к грани потеряна",
|
||||
"Faces": "Грани",
|
||||
@@ -101,6 +103,7 @@
|
||||
"Follow camera (Look At sketches)": "Следовать за камерой (эскизы «Взгляд на»)",
|
||||
"Follow the camera (Look At sketches)": "Следовать за камерой (эскизы «Взгляд на»)",
|
||||
"Font": "Шрифт",
|
||||
"From edges": "Начальные рёбра",
|
||||
"Full Length": "Полная длина",
|
||||
"Grid": "Сетка",
|
||||
"Height": "Высота",
|
||||
@@ -110,7 +113,9 @@
|
||||
"Interferes": "Пересекается",
|
||||
"Issue title": "Заголовок Issue",
|
||||
"Joint Origin1": "Точка сопряжения1",
|
||||
"Keep (points on it)": "Оставить (точки на нём)",
|
||||
"Keep Tools": "Оставить инструменты",
|
||||
"Keep of the other surface": "Оставить от другой поверхности",
|
||||
"Later": "Позже",
|
||||
"Leave it": "Оставить как есть",
|
||||
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Устаревшая привязка к грани. Перепривязка к сертифицированной грани создаёт точную привязку.",
|
||||
@@ -142,6 +147,7 @@
|
||||
"Open GitHub draft": "Открыть черновик на GitHub",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Открытая параметрическая 3D CAD на Rust. Лицензия MIT OR Apache-2.0.",
|
||||
"Other side": "Другая сторона",
|
||||
"Other surface (mutual trim)": "Другая поверхность (взаимная обрезка)",
|
||||
"Parameter values": "Значения параметров",
|
||||
"Pass through": "Через точки",
|
||||
"Path": "Траектория",
|
||||
@@ -218,10 +224,14 @@
|
||||
"Stand this part up?": "Поставить эту деталь вертикально?",
|
||||
"Step": "Шаг",
|
||||
"Steps": "Шаги",
|
||||
"Stitch the kept pieces": "Сшить оставленные части",
|
||||
"Stitch to both bodies": "Сшить с обоими телами",
|
||||
"Study": "Исследование",
|
||||
"Style": "Стиль",
|
||||
"Suppressed": "Подавлено",
|
||||
"Surface": "Поверхность",
|
||||
"Swap Ends": "Поменять концы",
|
||||
"TRIM": "ОБРЕЗКА",
|
||||
"Tangent (G1)": "Касание (G1)",
|
||||
"Tangent Chain": "Касательная цепочка",
|
||||
"Tear": "Разрыв",
|
||||
@@ -234,7 +244,9 @@
|
||||
"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.": "Эта грань не может обеспечить надёжную привязку. Выберите другую грань или явно используйте фиксированную плоскость.",
|
||||
"To Offset": "До смещения",
|
||||
"To edges": "Конечные рёбра",
|
||||
"Tolerance": "Допуск",
|
||||
"Trimming tool (plane, face or curves)": "Инструмент обрезки (плоскость, грань или кривые)",
|
||||
"Type": "Тип",
|
||||
"Updating the preview": "Обновление предпросмотра",
|
||||
"Use active author placement": "Использовать активное исходное положение",
|
||||
@@ -389,6 +401,7 @@
|
||||
"cmd.joint_rigid_group": "Жёсткая группа",
|
||||
"cmd.joint_screw": "Винт",
|
||||
"cmd.joint_solve": "Решить сопряжения",
|
||||
"cmd.joint_sweep": "Прогон сопряжения",
|
||||
"cmd.material_assign": "Физический материал",
|
||||
"cmd.material_create": "Новый физический материал",
|
||||
"cmd.material_delete": "Удалить физический материал",
|
||||
@@ -568,8 +581,10 @@
|
||||
"cmd.timeline_roll_to": "Переместить маркер истории",
|
||||
"cmd.timeline_rollback": "Сдвинуть маркер шкалы",
|
||||
"cmd.timeline_suppress": "Подавить операцию",
|
||||
"cmd.view_ambient_occlusion": "Окружающее затенение",
|
||||
"cmd.view_apply": "Вернуть именованный вид",
|
||||
"cmd.view_delete": "Удалить именованный вид",
|
||||
"cmd.view_graphics_preset": "Графический профиль",
|
||||
"cmd.view_list": "Список именованных видов",
|
||||
"cmd.view_navigate": "Навигация вида",
|
||||
"cmd.view_rename": "Переименовать именованный вид",
|
||||
@@ -750,6 +765,8 @@
|
||||
"ui.agent_cursor": "Курсор агента",
|
||||
"ui.agent_cursor_speed": "Скорость курсора агента",
|
||||
"ui.all": "Все",
|
||||
"ui.ambient_occlusion": "Окружающее затенение",
|
||||
"ui.ambient_occlusion_help": "Мягкое затенение в местах соприкосновения непрозрачных поверхностей. Требуется видовой экран GPU.",
|
||||
"ui.analysis": "Анализ",
|
||||
"ui.angle": "Угол",
|
||||
"ui.angle_decimals": "Знаков после запятой (углы)",
|
||||
@@ -764,6 +781,7 @@
|
||||
"ui.autosave_every": "Автосохранение каждые",
|
||||
"ui.axes": "Оси",
|
||||
"ui.axis": "Ось",
|
||||
"ui.back": "Назад",
|
||||
"ui.base_point": "Базовая точка",
|
||||
"ui.bodies": "Тела",
|
||||
"ui.box": "Бокс",
|
||||
@@ -899,6 +917,11 @@
|
||||
"ui.fully_constrained": "Полностью определён",
|
||||
"ui.general": "Общие",
|
||||
"ui.graphics": "Графика",
|
||||
"ui.graphics_custom": "Настраиваемый",
|
||||
"ui.graphics_performance": "Производительность",
|
||||
"ui.graphics_preset": "Графический профиль",
|
||||
"ui.graphics_preset_help": "Качество: окружающее затенение, детальная тесселяция и адаптивный суперсэмплинг 2×. Производительность: без затенения, обычная тесселяция и без суперсэмплинга. Ручные изменения отображаются как настраиваемые. MSAA не меняется.",
|
||||
"ui.graphics_quality": "Качество",
|
||||
"ui.ground_shadow": "Тень на земле",
|
||||
"ui.groupSelected": "Группировать выбранное",
|
||||
"ui.heading_add_ins": "НАДСТРОЙКИ",
|
||||
@@ -1116,6 +1139,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Выберите тела для сохранения",
|
||||
"ui.save_mesh_temp_failed": "Не удалось создать {folder}: {error}",
|
||||
"ui.saved_to": "Сохранено: {path}",
|
||||
"ui.sbox_pin": "Закрепить вверху панели S",
|
||||
"ui.sbox_unpin": "Открепить",
|
||||
"ui.scale": "Масштаб",
|
||||
"ui.section_analysis": "Анализ сечения",
|
||||
"ui.select": "Выбрать",
|
||||
@@ -1158,8 +1183,34 @@
|
||||
"ui.smooth_wheel_zoom": "Плавное масштабирование колесом",
|
||||
"ui.smooth_wheel_zoom_tip": "Отключите, чтобы каждое движение колеса применялось сразу, без сглаживания",
|
||||
"ui.snap": "Привязка",
|
||||
"ui.space_mouse_calibrate": "Калибровать…",
|
||||
"ui.space_mouse_calibrate_tip": "Двигайте колпачок так, как просит окно, чтобы SolidCraft узнал, куда и насколько далеко движется каждая ось этой 3D-мыши. Сохраняется для этой 3D-мыши.",
|
||||
"ui.space_mouse_calibrate_title": "Калибровка 3D-мыши",
|
||||
"ui.space_mouse_calibrated": "Откалибрована",
|
||||
"ui.space_mouse_calibration": "Калибровка 3D-мыши",
|
||||
"ui.space_mouse_calibration_done": "Готово. Сохраните её, чтобы она применялась всякий раз, когда подключена эта 3D-мышь.",
|
||||
"ui.space_mouse_calibration_no_response": "Одна ось почти не сдвинулась, поэтому её нельзя откалибровать. Начните заново и каждый раз двигайте колпачок дальше.",
|
||||
"ui.space_mouse_dead_zone": "Мёртвая зона",
|
||||
"ui.space_mouse_dead_zone_tip": "Насколько нужно сдвинуть колпачок, прежде чем сдвинется вид, в доле от полного хода. Увеличьте, если вид смещается, когда колпачок в покое.",
|
||||
"ui.space_mouse_forget": "Забыть калибровку",
|
||||
"ui.space_mouse_hint": "Совместное перемещение и вращение вида; в эскизе — только панорама и масштаб.",
|
||||
"ui.space_mouse_navigate": "Навигация через SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "3D-мышь не найдена",
|
||||
"ui.space_mouse_not_calibrated": "Не откалибрована",
|
||||
"ui.space_mouse_reading": "Показания",
|
||||
"ui.space_mouse_step_lift": "Поднимите колпачок вверх, удержите и отпустите.",
|
||||
"ui.space_mouse_step_press": "Нажмите на колпачок вниз, удержите и отпустите.",
|
||||
"ui.space_mouse_step_pull": "Потяните колпачок на себя, удержите и отпустите.",
|
||||
"ui.space_mouse_step_push": "Толкните колпачок от себя, удержите и отпустите.",
|
||||
"ui.space_mouse_step_rest": "Уберите руку с колпачка и не трогайте его.",
|
||||
"ui.space_mouse_step_slide_left": "Сдвиньте колпачок влево, удержите и отпустите.",
|
||||
"ui.space_mouse_step_slide_right": "Сдвиньте колпачок вправо, удержите и отпустите.",
|
||||
"ui.space_mouse_step_tilt_back": "Наклоните колпачок назад (передний край вверх), удержите и отпустите.",
|
||||
"ui.space_mouse_step_tilt_forward": "Наклоните колпачок вперёд (передний край вниз), удержите и отпустите.",
|
||||
"ui.space_mouse_step_tilt_left": "Наклоните колпачок влево (левый край вниз), удержите и отпустите.",
|
||||
"ui.space_mouse_step_tilt_right": "Наклоните колпачок вправо (правый край вниз), удержите и отпустите.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Поверните колпачок по часовой стрелке, удержите и отпустите.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Поверните колпачок против часовой стрелки, удержите и отпустите.",
|
||||
"ui.spacing": "Интервал",
|
||||
"ui.spacing_2": "Интервал 2",
|
||||
"ui.sphere": "Сфера",
|
||||
@@ -1167,6 +1218,7 @@
|
||||
"ui.split_body": "Разделить тело",
|
||||
"ui.stand_up_tip": "Если файл STEP или IGES открывается с Z вверх, предложить поворот на четверть оборота вокруг X",
|
||||
"ui.start": "Старт",
|
||||
"ui.start_over": "Начать заново",
|
||||
"ui.start_radius": "Начальный радиус",
|
||||
"ui.suppress": "Подавить",
|
||||
"ui.sweep": "Сдвиг",
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"Countersink angle": "Konik havşa açısı",
|
||||
"Create Issue — review draft": "Sorun oluştur — taslağı gözden geçirin",
|
||||
"Cube": "Küp",
|
||||
"Curvature change (G3)": "Eğrilik değişimi (G3)",
|
||||
"Customize Toolbar": "Araç çubuğunu özelleştir",
|
||||
"Delete all": "Tümünü sil",
|
||||
"Delete rule": "Kuralı sil",
|
||||
@@ -92,6 +93,7 @@
|
||||
"Enabled": "Etkin",
|
||||
"Explode": "Patlat",
|
||||
"Export Animation…": "Animasyonu dışa aktar…",
|
||||
"Extend to meet": "Buluşana kadar uzat",
|
||||
"Extents": "Kapsamlar",
|
||||
"Face": "Yüz",
|
||||
"Face attachment lost": "Yüze bağlama kayboldu",
|
||||
@@ -106,6 +108,7 @@
|
||||
"Follow camera (Look At sketches)": "Kamera takibi (eskizlerde Bak)",
|
||||
"Follow the camera (Look At sketches)": "Kamera takibi (eskizlerde Bak)",
|
||||
"Font": "Yazı tipi",
|
||||
"From edges": "Başlangıç kenarları",
|
||||
"Full Length": "Tam uzunluk",
|
||||
"Grid": "Izgara",
|
||||
"Height": "Yükseklik",
|
||||
@@ -119,7 +122,9 @@
|
||||
"Interferes": "Girişim var",
|
||||
"Issue title": "Sorun başlığı",
|
||||
"Joint Origin1": "Bağlantı orijini1",
|
||||
"Keep (points on it)": "Koru (üzerindeki noktalar)",
|
||||
"Keep Tools": "Araçları koru",
|
||||
"Keep of the other surface": "Diğer yüzeyden korunacak",
|
||||
"Later": "Daha sonra",
|
||||
"Leave it": "Olduğu gibi bırak",
|
||||
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Eski tip yüz bağlaması. Doğrulanmış bir yüze yeniden bağlamak tam bir bağlama oluşturur.",
|
||||
@@ -154,6 +159,7 @@
|
||||
"Open GitHub draft": "GitHub taslağını aç",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Rust ile yazılmış açık kaynaklı parametrik 3D CAD. Lisans: MIT OR Apache-2.0.",
|
||||
"Other side": "Diğer taraf",
|
||||
"Other surface (mutual trim)": "Diğer yüzey (karşılıklı kırpma)",
|
||||
"Outside": "Dış",
|
||||
"Overall length": "Toplam uzunluk",
|
||||
"Parameter values": "Parametre değerleri",
|
||||
@@ -236,10 +242,14 @@
|
||||
"Stand this part up?": "Bu parça dik çevrilsin mi?",
|
||||
"Step": "Adım",
|
||||
"Steps": "Adımlar",
|
||||
"Stitch the kept pieces": "Korunan parçaları dik",
|
||||
"Stitch to both bodies": "Her iki gövdeye dik",
|
||||
"Study": "Etüt",
|
||||
"Style": "Stil",
|
||||
"Suppressed": "Bastırılmış",
|
||||
"Surface": "Yüzey",
|
||||
"Swap Ends": "Uçları değiştir",
|
||||
"TRIM": "KIRP",
|
||||
"Tangent Chain": "Teğet zincir",
|
||||
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Kesikli eskiz, kaydedilmiş bir düzlem kılavuzudur. Bağlamayı onarmak için yeni bir yüz seçin.",
|
||||
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Kaydedilmiş kaynak örnek eksik. Yeniden bağlamadan önce geçerli bir kaynak yerleşimi seçin.",
|
||||
@@ -253,7 +263,9 @@
|
||||
"This child is shared. Position changes apply to every copy of its parent.": "Bu alt öğe paylaşılıyor. Konum değişiklikleri üst öğesinin tüm kopyalarına uygulanır.",
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Bu yüz kalıcı bir bağlama sağlayamaz. Başka bir yüz seçin veya açıkça sabit bir düzlem kullanın.",
|
||||
"To Offset": "Ofsete kadar",
|
||||
"To edges": "Bitiş kenarları",
|
||||
"Tolerance": "Tolerans",
|
||||
"Trimming tool (plane, face or curves)": "Kırpma aracı (düzlem, yüz veya eğriler)",
|
||||
"Type": "Tür",
|
||||
"UNTRIM": "KIRPMAYI KALDIR",
|
||||
"Updating the preview": "Önizleme güncelleniyor",
|
||||
@@ -399,6 +411,7 @@
|
||||
"cmd.joint_origin": "Bağlantı orijini",
|
||||
"cmd.joint_rigid_group": "Rijit grup",
|
||||
"cmd.joint_solve": "Bağlantıları çöz",
|
||||
"cmd.joint_sweep": "Bağlantıyı tara",
|
||||
"cmd.library_apply": "Yerel kitaplık girişini uygula",
|
||||
"cmd.library_export": "Tasarım kitaplığını dışa aktar",
|
||||
"cmd.library_inspect": "Yerel kitaplığı incele",
|
||||
@@ -905,6 +918,7 @@
|
||||
"ui.autosave_every": "Otomatik kaydetme aralığı",
|
||||
"ui.axes": "Eksenler",
|
||||
"ui.axis": "Eksen",
|
||||
"ui.back": "Geri",
|
||||
"ui.base_point": "Temel nokta",
|
||||
"ui.blocked": "Engellenmiş",
|
||||
"ui.bodies": "Gövdeler",
|
||||
@@ -1264,6 +1278,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Kaydedilecek gövdeleri seçin",
|
||||
"ui.save_mesh_temp_failed": "{folder} oluşturulamadı: {error}",
|
||||
"ui.saved_to": "{path} konumuna kaydedildi",
|
||||
"ui.sbox_pin": "S kutusunun en üstüne sabitle",
|
||||
"ui.sbox_unpin": "Sabitlemeyi kaldır",
|
||||
"ui.scale": "Ölçekle",
|
||||
"ui.section_analysis": "Kesit analizi",
|
||||
"ui.select": "Seç",
|
||||
@@ -1306,8 +1322,34 @@
|
||||
"ui.smooth_wheel_zoom": "Yumuşak tekerlek yakınlaştırması",
|
||||
"ui.smooth_wheel_zoom_tip": "Her tekerlek hareketini yumuşatma olmadan hemen uygulamak için kapatın",
|
||||
"ui.snap": "Yakala",
|
||||
"ui.space_mouse_calibrate": "Kalibre et…",
|
||||
"ui.space_mouse_calibrate_tip": "Pencerenin istediği gibi başlığı hareket ettirin; SolidCraft bu 3B farenin her ekseninin hangi yöne ve ne kadar gittiğini öğrensin. Bu 3B fare için kaydedilir.",
|
||||
"ui.space_mouse_calibrate_title": "3B fareyi kalibre et",
|
||||
"ui.space_mouse_calibrated": "Kalibre edildi",
|
||||
"ui.space_mouse_calibration": "3B fare kalibrasyonu",
|
||||
"ui.space_mouse_calibration_done": "Bitti. Bu 3B fare her bağlandığında kullanılması için kaydedin.",
|
||||
"ui.space_mouse_calibration_no_response": "Bir eksen neredeyse hiç hareket etmedi, bu yüzden kalibre edilemiyor. Baştan başlayın ve başlığı her seferinde daha fazla hareket ettirin.",
|
||||
"ui.space_mouse_dead_zone": "Ölü bölge",
|
||||
"ui.space_mouse_dead_zone_tip": "Görünüm hareket etmeden önce başlığın ne kadar hareket etmesi gerektiği, tam hareket aralığının oranı olarak. Başlık dururken görünüm kayıyorsa artırın.",
|
||||
"ui.space_mouse_forget": "Kalibrasyonu unut",
|
||||
"ui.space_mouse_hint": "Görünümü aynı anda kaydırın ve döndürün; eskizlerde yalnızca kaydırma ve yakınlaştırma kullanılır.",
|
||||
"ui.space_mouse_navigate": "SpaceNavigator / SpaceMouse ile gezin",
|
||||
"ui.space_mouse_none": "3B fare bulunamadı",
|
||||
"ui.space_mouse_not_calibrated": "Kalibre edilmedi",
|
||||
"ui.space_mouse_reading": "Okunan değer",
|
||||
"ui.space_mouse_step_lift": "Başlığı yukarı kaldırın, tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_press": "Başlığı aşağı bastırın, tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_pull": "Başlığı kendinize doğru çekin, tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_push": "Başlığı sizden uzağa itin, tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_rest": "Elinizi başlıktan çekin ve hareketsiz bırakın.",
|
||||
"ui.space_mouse_step_slide_left": "Başlığı sola kaydırın, tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_slide_right": "Başlığı sağa kaydırın, tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_tilt_back": "Başlığı geriye eğin (ön kenar yukarı), tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_tilt_forward": "Başlığı öne eğin (ön kenar aşağı), tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_tilt_left": "Başlığı sola eğin (sol kenar aşağı), tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_tilt_right": "Başlığı sağa eğin (sağ kenar aşağı), tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Başlığı saat yönünde çevirin, tutun, sonra bırakın.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Başlığı saat yönünün tersine çevirin, tutun, sonra bırakın.",
|
||||
"ui.spacing": "Aralık",
|
||||
"ui.spacing_2": "Aralık 2",
|
||||
"ui.sphere": "Küre",
|
||||
@@ -1315,6 +1357,7 @@
|
||||
"ui.split_body": "Gövdeyi böl",
|
||||
"ui.stand_up_tip": "Bir STEP veya IGES dosyası Z yukarı olarak açıldığında X etrafında çeyrek tur döndürmeyi öner",
|
||||
"ui.start": "Başlangıç",
|
||||
"ui.start_over": "Baştan başla",
|
||||
"ui.start_radius": "Başlangıç yarıçapı",
|
||||
"ui.supersampling": "Süper örnekleme (SSAA)",
|
||||
"ui.supersampling_adaptive": "Yalnızca görünüm hareketsizken",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"Arc length": "Довжина дуги",
|
||||
"Area": "Площа",
|
||||
"Attachment": "Прив'язка",
|
||||
"BOUNDARY SURFACE": "ПОВЕРХНЯ ЗА МЕЖАМИ",
|
||||
"BROWSER": "БРАУЗЕР",
|
||||
"Bend Line Position": "Положення лінії згину",
|
||||
"Bend Position": "Положення згину",
|
||||
@@ -73,10 +74,12 @@
|
||||
"Counterbore depth": "Глибина циліндричного розширення",
|
||||
"Countersink angle": "Кут зенкування",
|
||||
"Create Issue — review draft": "Створити повідомлення про проблему — перегляд чернетки",
|
||||
"Cross curves": "Поперечні криві",
|
||||
"Cube": "Куб",
|
||||
"Current orthographic view (1:1 mm)": "Поточний ортографічний вид (1:1 mm)",
|
||||
"Current perspective view": "Поточний перспективний вид",
|
||||
"Curvature (G2)": "Кривина (G2)",
|
||||
"Curvature change (G3)": "Зміна кривини (G3)",
|
||||
"Customize Toolbar": "Налаштувати панель інструментів",
|
||||
"Delete all": "Видалити все",
|
||||
"Delete rule": "Видалити правило",
|
||||
@@ -89,6 +92,7 @@
|
||||
"Direction": "Напрямок",
|
||||
"Discard": "Відхилити",
|
||||
"Do not export hidden edges": "Не експортувати приховані ребра",
|
||||
"Don't ask again": "Більше не питати",
|
||||
"Double-click to rename": "Двічі клацніть, щоб перейменувати",
|
||||
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Подвійне клацання середньою кнопкою у виді: головний (головний вид, що охоплює показане), вписування (охопити показане, зберігаючи орієнтацію) або нічого",
|
||||
"Drive Joint": "Керувати з'єднанням",
|
||||
@@ -98,11 +102,13 @@
|
||||
"Enable rule": "Увімкнути правило",
|
||||
"Enabled": "Увімкнено",
|
||||
"Explode": "Рознести",
|
||||
"Explode Text": "Розібрати текст",
|
||||
"Export Animation…": "Експортувати анімацію…",
|
||||
"Export Current View as DXF": "Експортувати поточний вид як DXF",
|
||||
"Export Current View as DXF…": "Експортувати поточний вид як DXF…",
|
||||
"Export DXF…": "Експорт DXF…",
|
||||
"Export hidden edges as dashed lines": "Експортувати приховані ребра як штрихові лінії",
|
||||
"Extend to meet": "Подовжити до перетину",
|
||||
"Extents": "Межі",
|
||||
"Face": "Грань",
|
||||
"Face attachment lost": "Втрачено прив'язку до грані",
|
||||
@@ -117,6 +123,7 @@
|
||||
"Follow camera (Look At sketches)": "Стежити за камерою (ескізи наведення)",
|
||||
"Follow the camera (Look At sketches)": "Стежити за камерою (ескізи наведення)",
|
||||
"Font": "Шрифт",
|
||||
"From edges": "Початкові ребра",
|
||||
"Full Length": "Повна довжина",
|
||||
"Grid": "Сітка",
|
||||
"Height": "Висота",
|
||||
@@ -131,7 +138,9 @@
|
||||
"Interferes": "Перетинається",
|
||||
"Issue title": "Заголовок повідомлення",
|
||||
"Joint Origin1": "Початок з'єднання1",
|
||||
"Keep (points on it)": "Залишити (точки на ньому)",
|
||||
"Keep Tools": "Зберегти інструменти",
|
||||
"Keep of the other surface": "Залишити від іншої поверхні",
|
||||
"Later": "Пізніше",
|
||||
"Leave it": "Залишити як є",
|
||||
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Застаріла прив'язка до грані. Повторна прив'язка до перевіреної грані створює точну прив'язку.",
|
||||
@@ -156,6 +165,7 @@
|
||||
"Move earlier": "Перемістити раніше",
|
||||
"Move later": "Перемістити пізніше",
|
||||
"Name": "Назва",
|
||||
"Nearest": "Найближчі",
|
||||
"New study": "Нове дослідження",
|
||||
"No crash reports.": "Немає звітів про збої.",
|
||||
"No matching appearances": "Немає відповідних виглядів",
|
||||
@@ -168,6 +178,7 @@
|
||||
"Open GitHub draft": "Відкрити чернетку GitHub",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Параметричний 3D-CAD з відкритим вихідним кодом на Rust. Ліцензовано за MIT OR Apache-2.0.",
|
||||
"Other side": "Інший бік",
|
||||
"Other surface (mutual trim)": "Інша поверхня (взаємне обрізання)",
|
||||
"Outside": "Назовні",
|
||||
"Overall length": "Загальна довжина",
|
||||
"Parameter values": "Значення параметрів",
|
||||
@@ -184,6 +195,7 @@
|
||||
"Position (0 to 1)": "Положення (від 0 до 1)",
|
||||
"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 Row": "Рядок перегляду",
|
||||
"Preview leaves the current design unchanged.": "Попередній перегляд не змінює поточний проєкт.",
|
||||
"Profiles/Curves": "Профілі/криві",
|
||||
@@ -254,10 +266,14 @@
|
||||
"Stand this part up?": "Вирівняти цю деталь?",
|
||||
"Step": "Крок",
|
||||
"Steps": "Кроки",
|
||||
"Stitch the kept pieces": "Зшити залишені частини",
|
||||
"Stitch to both bodies": "Зшити з обома тілами",
|
||||
"Study": "Дослідження",
|
||||
"Style": "Стиль",
|
||||
"Suppressed": "Придушено",
|
||||
"Surface": "Поверхня",
|
||||
"Swap Ends": "Поміняти кінці місцями",
|
||||
"TRIM": "ОБРІЗАННЯ",
|
||||
"Tangent (G1)": "Дотик (G1)",
|
||||
"Tangent Chain": "Дотичний ланцюг",
|
||||
"Tear": "Розрив",
|
||||
@@ -273,7 +289,10 @@
|
||||
"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.": "Ця грань не може забезпечити надійну прив'язку. Виберіть іншу грань або явно використайте фіксовану площину.",
|
||||
"To Offset": "До зсуву",
|
||||
"To edges": "Кінцеві ребра",
|
||||
"Tolerance": "Допуск",
|
||||
"Too close": "Занадто близько",
|
||||
"Trimming tool (plane, face or curves)": "Інструмент обрізання (площина, грань або криві)",
|
||||
"Turn off section analysis before exporting the view.": "Вимкніть аналіз перерізу перед експортом виду.",
|
||||
"Type": "Тип",
|
||||
"UNTRIM": "ЗНЯТИ ОБРІЗАННЯ",
|
||||
@@ -407,6 +426,7 @@
|
||||
"cmd.file_slicers": "Встановлені слайсери",
|
||||
"cmd.inspect_accessibility": "Аналіз доступності",
|
||||
"cmd.inspect_center_of_mass": "Центр мас",
|
||||
"cmd.inspect_clearance": "Зазор",
|
||||
"cmd.inspect_compare": "Порівняти тіла",
|
||||
"cmd.inspect_continuity": "Аналіз неперервності",
|
||||
"cmd.inspect_curvature_comb": "Аналіз гребеня кривизни",
|
||||
@@ -416,6 +436,7 @@
|
||||
"cmd.inspect_interference": "Перевірка перетинів",
|
||||
"cmd.inspect_isocurve": "Аналіз ізокривих",
|
||||
"cmd.inspect_minimum_radius": "Аналіз мінімального радіуса",
|
||||
"cmd.inspect_silhouette": "Силует",
|
||||
"cmd.inspect_zebra": "Зебра-аналіз",
|
||||
"cmd.joint_as_built": "З'єднання за поточним положенням",
|
||||
"cmd.joint_cam": "Кулачок",
|
||||
@@ -433,6 +454,7 @@
|
||||
"cmd.joint_rigid_group": "Жорстка група",
|
||||
"cmd.joint_screw": "Гвинт",
|
||||
"cmd.joint_solve": "Розв'язати з'єднання",
|
||||
"cmd.joint_sweep": "Прогін з'єднання",
|
||||
"cmd.library_apply": "Застосувати елемент локальної бібліотеки",
|
||||
"cmd.library_export": "Експортувати бібліотеку проєкту",
|
||||
"cmd.library_inspect": "Перевірити локальну бібліотеку",
|
||||
@@ -550,6 +572,8 @@
|
||||
"cmd.sketch_isoparametric_curve": "Ізопараметрична крива",
|
||||
"cmd.sketch_line3d": "3D-пряма",
|
||||
"cmd.sketch_line_midpoint": "Пряма за середньою точкою",
|
||||
"cmd.sketch_loose_ends": "Вільні кінці",
|
||||
"cmd.sketch_loose_ends_tie": "Зв'язати вільні кінці",
|
||||
"cmd.sketch_move": "Перемістити/копіювати елементи ескізу",
|
||||
"cmd.sketch_move3d": "Перемістити 3D-точку",
|
||||
"cmd.sketch_move_point": "Перетягнути точку ескізу",
|
||||
@@ -594,8 +618,10 @@
|
||||
"cmd.surface_blend": "Сполучна поверхня",
|
||||
"cmd.surface_boundary": "Поверхня за межами",
|
||||
"cmd.surface_extrude": "Видавлювання поверхні",
|
||||
"cmd.surface_fair": "Згладжування поверхні",
|
||||
"cmd.surface_fillet": "Заокруглення поверхні",
|
||||
"cmd.surface_loft": "Лофт поверхні",
|
||||
"cmd.surface_match": "Вирівнювання поверхні",
|
||||
"cmd.surface_offset": "Зсунута поверхня",
|
||||
"cmd.surface_patch": "Латка",
|
||||
"cmd.surface_reverse_normal": "Обернути нормаль",
|
||||
@@ -619,8 +645,10 @@
|
||||
"cmd.timeline_roll_to": "Перемістити маркер історії",
|
||||
"cmd.timeline_rollback": "Перемістити маркер часового ланцюжка",
|
||||
"cmd.timeline_suppress": "Придушити операцію",
|
||||
"cmd.view_ambient_occlusion": "Окружне затінення",
|
||||
"cmd.view_apply": "Відновити іменований вид",
|
||||
"cmd.view_delete": "Видалити іменований вид",
|
||||
"cmd.view_graphics_preset": "Набір графіки",
|
||||
"cmd.view_list": "Список іменованих видів",
|
||||
"cmd.view_navigate": "Навігація видом",
|
||||
"cmd.view_rename": "Перейменувати іменований вид",
|
||||
@@ -642,7 +670,12 @@
|
||||
"coupling.rack": "Рейка",
|
||||
"coupling.radius_1": "Радіус 1",
|
||||
"coupling.radius_2": "Радіус 2",
|
||||
"dialog.bodies_count": "Тіл: {count}",
|
||||
"dialog.clearance_invalid": "зазор має бути довжиною 0 або більше",
|
||||
"dialog.compute": "Обчислити",
|
||||
"dialog.compute_again": "Обчислити знову",
|
||||
"dialog.computing": "Обчислення… {seconds} с",
|
||||
"dialog.could_not_be_checked": "не вдалося перевірити",
|
||||
"dialog.custom_axis": "↖ Власна вісь",
|
||||
"dialog.edit_title": "РЕДАГУВАННЯ {title}",
|
||||
"dialog.select": "↖ Вибрати",
|
||||
@@ -655,6 +688,18 @@
|
||||
"drive.stopped_by_contact": "Зупинка на {value}: {a} пройшло б крізь {b}",
|
||||
"drive.stopped_by_loop": "Зупинка на {value}: контур через {joint} далі не замикається",
|
||||
"drive.unconnected": "Нічого не рухається: {part} не з'єднано ні з чим закріпленим",
|
||||
"dxf.cancel_wait": "Очікування зупинки експорту…",
|
||||
"dxf.cancelled": "Експорт DXF скасовано",
|
||||
"dxf.cancelling": "Скасування експорту DXF",
|
||||
"dxf.exporting": "Експортування DXF",
|
||||
"dxf.invalid_tolerance": "Допуск кривої має бути від 0,0001 до 1 мм.",
|
||||
"dxf.progress.index": "Підготовка поверхонь (2/5)",
|
||||
"dxf.progress.meshes": "Підготовка сіток (1/5)",
|
||||
"dxf.progress.occlusion": "Обчислення невидимих ліній (4/5)",
|
||||
"dxf.progress.projection": "Проєктування геометрії (3/5)",
|
||||
"dxf.progress.write": "Запис DXF (5/5)",
|
||||
"dxf.tolerance": "Допуск кривої",
|
||||
"dxf.tolerance_help": "Лише видимі тіла. Криві подаються відрізками. Менший допуск дає точніші криві, але потребує більше часу й пам'яті.",
|
||||
"expression, e.g. 25 mm": "вираз, напр. 25 mm",
|
||||
"fits the face, e.g. M8": "відповідає грані, напр. M8",
|
||||
"from the rule": "з правила",
|
||||
@@ -700,9 +745,11 @@
|
||||
"new configuration": "нова конфігурація",
|
||||
"new name": "нова назва",
|
||||
"new parameter": "новий параметр",
|
||||
"no hole": "без отвору",
|
||||
"no interference": "немає перетинів",
|
||||
"no joint that moves yet": "ще немає рухомого з'єднання",
|
||||
"no picture": "немає зображення",
|
||||
"none": "немає",
|
||||
"pattern.alignment_2": "Вирівнювання 2",
|
||||
"pattern.alignment_hint": "Кількість включає оригінал. Центрування розподіляє копії з обох боків; за парної кількості зміна напрямку визначає бік із додатковою копією.",
|
||||
"pattern.direction_1": "Напрямок 1",
|
||||
@@ -775,6 +822,8 @@
|
||||
"sketch.degrees_of_freedom": "{count} ступенів вільності",
|
||||
"sketch.draw_curves": "Малювати як {linetype}",
|
||||
"sketch.make_selection": "Перетворити виділене на {linetype}",
|
||||
"sketch.open_ends": "Відкритих: {count}",
|
||||
"sketch.untied_ends": "Незв'язаних: {count}",
|
||||
"sketch_text.align_center": "По центру",
|
||||
"sketch_text.align_left": "Ліворуч",
|
||||
"sketch_text.align_right": "Праворуч",
|
||||
@@ -787,13 +836,19 @@
|
||||
"sketch_text.path_clear": "Зняти текст з його кривої",
|
||||
"sketch_text.path_hint": "Крива ескізу, якої дотримується текст: клацніть тут, а потім на кривій",
|
||||
"sketch_text.use_font_file": "Використати файл шрифту {path}",
|
||||
"solid": "суцільне",
|
||||
"the parts library is not available": "бібліотека деталей недоступна",
|
||||
"thread.automatic_size": "Автоматичний розмір",
|
||||
"thread.designation": "Позначення",
|
||||
"thread.iso_metric": "Метрична ISO",
|
||||
"thread.select_size": "Вибрати розмір",
|
||||
"timeline.group": "Нова тека операцій",
|
||||
"timeline.move_many": "Перемістити елементи часового ланцюжка",
|
||||
"timeline.move_to_group": "Перемістити операції до теки",
|
||||
"timeline.rename_group": "Перейменувати теку операцій",
|
||||
"timeline.reorder_many": "Перевпорядкувати операції",
|
||||
"timeline.ungroup": "Прибрати теки операцій",
|
||||
"to the face": "до грані",
|
||||
"to the next face": "до наступної грані",
|
||||
"toolbar.image": "ЗОБРАЖЕННЯ",
|
||||
"toolbar.render": "РЕНДЕР",
|
||||
@@ -809,12 +864,15 @@
|
||||
"ui.activate": "Активувати",
|
||||
"ui.active_component": "Активний компонент",
|
||||
"ui.agent_chat": "Чат з агентом",
|
||||
"ui.agent_chat_add": "Прикріпити файли",
|
||||
"ui.agent_chat_add_file": "Вибрати файл…",
|
||||
"ui.agent_chat_allow": "Дозволити",
|
||||
"ui.agent_chat_allowed": "Дозволено",
|
||||
"ui.agent_chat_answered": "Відповідь: {option}",
|
||||
"ui.agent_chat_approval": "Дозволити агенту це зробити?",
|
||||
"ui.agent_chat_attach_view": "Прикріпити вид",
|
||||
"ui.agent_chat_attach_view_tip": "Надіслати знімок поточного виду разом із повідомленням",
|
||||
"ui.agent_chat_attachable": "Зображення, текст і CAD-файли",
|
||||
"ui.agent_chat_auth_agent": "Увійти через {name}",
|
||||
"ui.agent_chat_auth_env": "Задайте {vars} у своєму середовищі та перезапустіть SolidCraft.",
|
||||
"ui.agent_chat_auth_needed": "Агент просить вас увійти. Ви входите через сам агент; SolidCraft ніколи не бачить вашого пароля чи ключів.",
|
||||
@@ -834,11 +892,32 @@
|
||||
"ui.agent_chat_deny": "Відхилити",
|
||||
"ui.agent_chat_desktop_only": "Чат з агентом потребує настільної програми.",
|
||||
"ui.agent_chat_detecting": "Пошук агентів…",
|
||||
"ui.agent_chat_dock": "Закріпити на бічній панелі",
|
||||
"ui.agent_chat_dock_left": "Закріпити ліворуч",
|
||||
"ui.agent_chat_dock_right": "Закріпити праворуч",
|
||||
"ui.agent_chat_drop_hint": "Перетягніть, щоб прикріпити до наступного повідомлення",
|
||||
"ui.agent_chat_edit": "Редагувати",
|
||||
"ui.agent_chat_exited": "Агент зупинився.",
|
||||
"ui.agent_chat_failed_start": "Не вдалося запустити агента: {error}",
|
||||
"ui.agent_chat_file_cad_not_read": "{name}: CAD-файл імпортується в проєкт, а не читається",
|
||||
"ui.agent_chat_file_empty_picture": "{name}: порожнє зображення",
|
||||
"ui.agent_chat_file_error": "{name}: {error}",
|
||||
"ui.agent_chat_file_heic": "{name}: зображення HEIC тут не читаються; спершу експортуйте його як JPEG або PNG",
|
||||
"ui.agent_chat_file_importing": "Імпортування в проєкт…",
|
||||
"ui.agent_chat_file_loading": "Читання файлу…",
|
||||
"ui.agent_chat_file_not_a_file": "{name}: не файл",
|
||||
"ui.agent_chat_file_not_a_picture": "{name}: не зображення",
|
||||
"ui.agent_chat_file_not_utf8": "{name}: не текст UTF-8",
|
||||
"ui.agent_chat_file_picture_too_big": "{name}: зображення може бути щонайбільше {max} МБ",
|
||||
"ui.agent_chat_file_text_too_big": "{name}: текстовий файл може бути щонайбільше {max} КБ",
|
||||
"ui.agent_chat_file_too_large": "{name}: завеликий",
|
||||
"ui.agent_chat_file_too_large_to_send": "{name}: завеликий для надсилання, навіть зменшеним",
|
||||
"ui.agent_chat_file_unreadable_picture": "{name}: не зображення, яке це може прочитати",
|
||||
"ui.agent_chat_files": "Файли: {files}",
|
||||
"ui.agent_chat_files_left_out": "З одним повідомленням надсилається щонайбільше {max} файлів; пропущено: {files}",
|
||||
"ui.agent_chat_files_loading": "Очікування читання файлів",
|
||||
"ui.agent_chat_files_max": "Щонайбільше {max} файлів на одне повідомлення",
|
||||
"ui.agent_chat_import_failed": "{name}: не вдалося імпортувати: {error}",
|
||||
"ui.agent_chat_intro": "Спілкуйтеся з агентом для програмування, яким уже користуєтеся, або з обраною вами модельною кінцевою точкою. Він працює з відкритим проєктом через інструменти SolidCraft: його зміни з'являються в міру виконання й скасовуються одним кроком на повідомлення. Що він може робити без запиту, задано в Налаштування > ШІ.",
|
||||
"ui.agent_chat_key_in_env": "ключ задано",
|
||||
"ui.agent_chat_left": "Ліворуч",
|
||||
@@ -851,14 +930,18 @@
|
||||
"ui.agent_chat_no_cli": "solidcraft-cli не знайдено поруч із програмою, тож агент не може дістатися до проєкту.",
|
||||
"ui.agent_chat_no_control": "Кінцева точка чату з агентом не запустилася, тож агент не може дістатися до проєкту.",
|
||||
"ui.agent_chat_no_images": "Цей агент не приймає зображень",
|
||||
"ui.agent_chat_no_picture": "У буфері обміну немає зображення",
|
||||
"ui.agent_chat_none_found": "Агента для програмування не знайдено. Установіть один із цих, а потім натисніть «Оновити»:",
|
||||
"ui.agent_chat_none_found_short": "Агента не знайдено",
|
||||
"ui.agent_chat_not_installed": "Не встановлено",
|
||||
"ui.agent_chat_not_signed_in": "не виконано вхід",
|
||||
"ui.agent_chat_own_window": "Власне вікно",
|
||||
"ui.agent_chat_paste_picture": "Вставити зображення",
|
||||
"ui.agent_chat_permission": "Агент просить виконати: {title}",
|
||||
"ui.agent_chat_pick": "Агент",
|
||||
"ui.agent_chat_placeholder": "Попросіть агента змінити проєкт…",
|
||||
"ui.agent_chat_placeholder_busy": "Поставте повідомлення в чергу, доки агент завершить (Ctrl+Enter: надіслати зараз)",
|
||||
"ui.agent_chat_pop_out": "Відкрити у власному вікні, яке можна перемістити на інший екран",
|
||||
"ui.agent_chat_privacy": "Нічого нікуди не надсилається, крім агента, який ви запускаєте, під вашим власним обліковим записом.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft нічого нікуди не надсилає: агент, який ви запускаєте, спілкується з власним сервісом під вашим обліковим записом. SolidCraft ніколи не бачить і не зберігає ключ чи пароль.",
|
||||
"ui.agent_chat_queue": "У чергу",
|
||||
@@ -868,6 +951,8 @@
|
||||
"ui.agent_chat_refresh": "Оновити",
|
||||
"ui.agent_chat_refused": "Не дозволено для агента: {reason}",
|
||||
"ui.agent_chat_remove": "Прибрати з черги",
|
||||
"ui.agent_chat_remove_file": "Прибрати з цього повідомлення",
|
||||
"ui.agent_chat_remove_imported": "Прибрати з цього повідомлення; деталь залишається в проєкті (скасування її прибирає)",
|
||||
"ui.agent_chat_right": "Праворуч",
|
||||
"ui.agent_chat_save": "Зберегти",
|
||||
"ui.agent_chat_send": "Надіслати",
|
||||
@@ -965,6 +1050,8 @@
|
||||
"ui.ai_toolbar_button_note": "Кнопка ШІ в правому кінці панелі інструментів. Ctrl+Shift+A і меню «Вид» усе одно відкривають панель.",
|
||||
"ui.ai_undo_tip": "Скасувати все, що агент змінив для цього повідомлення, одним кроком",
|
||||
"ui.all": "Усі",
|
||||
"ui.ambient_occlusion": "Окружне затінення",
|
||||
"ui.ambient_occlusion_help": "М'яке затінення в місцях, де стикаються непрозорі поверхні. Потребує вікна перегляду на GPU.",
|
||||
"ui.analysis": "Аналіз",
|
||||
"ui.angle": "Кут",
|
||||
"ui.angle_decimals": "Знаки після коми для кутів",
|
||||
@@ -979,9 +1066,11 @@
|
||||
"ui.autosave_every": "Автозбереження кожні",
|
||||
"ui.axes": "Осі",
|
||||
"ui.axis": "Вісь",
|
||||
"ui.back": "Назад",
|
||||
"ui.base_point": "Базова точка",
|
||||
"ui.blocked": "Заблоковані",
|
||||
"ui.bodies": "Тіла",
|
||||
"ui.bodies_all_if_none": "Тіла (усі, якщо не вибрано)",
|
||||
"ui.box": "Куб",
|
||||
"ui.break": "Розірвати",
|
||||
"ui.calibrate": "Калібрувати",
|
||||
@@ -1033,6 +1122,9 @@
|
||||
"ui.construct": "Побудувати",
|
||||
"ui.construction": "Конструкційна",
|
||||
"ui.context_menu": "Контекстне меню",
|
||||
"ui.continuity_g0": "G0 злам",
|
||||
"ui.continuity_g1": "G1 дотичність",
|
||||
"ui.continuity_g2": "G2 кривина",
|
||||
"ui.continuity_gap": "Проміжок",
|
||||
"ui.copy": "Копіювати",
|
||||
"ui.corner": "Кут",
|
||||
@@ -1121,6 +1213,13 @@
|
||||
"ui.fully_constrained": "Повністю визначений",
|
||||
"ui.general": "Загальні",
|
||||
"ui.graphics": "Графіка",
|
||||
"ui.graphics_custom": "Власний",
|
||||
"ui.graphics_performance": "Продуктивність",
|
||||
"ui.graphics_preset": "Набір графіки",
|
||||
"ui.graphics_preset_help": "Якість: окружне затінення, висока якість відображення та адаптивна наддискретизація 2×. Продуктивність: без окружного затінення, звичайна якість відображення та вимкнена наддискретизація. Ручні зміни показують «Власний». MSAA не змінюється.",
|
||||
"ui.graphics_quality": "Якість",
|
||||
"ui.grid_follows_view": "Сітка слідує за видом",
|
||||
"ui.grid_follows_view_hint": "Бічні види показують сітку на вертикальній площині, повернутій до них, а не на землі",
|
||||
"ui.ground_shadow": "Тінь на землі",
|
||||
"ui.groupSelected": "Згрупувати виділене",
|
||||
"ui.heading_add_ins": "НАДБУДОВИ",
|
||||
@@ -1147,6 +1246,7 @@
|
||||
"ui.heading_home": "ГОЛОВНА",
|
||||
"ui.heading_insert": "ВСТАВИТИ",
|
||||
"ui.heading_inspect": "ПЕРЕВІРКА",
|
||||
"ui.heading_interference": "ПЕРЕТИНИ",
|
||||
"ui.heading_loft": "ЛОФТ",
|
||||
"ui.heading_make": "ВИГОТОВЛЕННЯ",
|
||||
"ui.heading_measure": "ВИМІРЮВАННЯ",
|
||||
@@ -1271,6 +1371,7 @@
|
||||
"ui.one_side": "Один бік",
|
||||
"ui.open": "Відкрити",
|
||||
"ui.open_ellipsis": "Відкрити...",
|
||||
"ui.open_ends_tip": "Нічого не торкається",
|
||||
"ui.open_preferences": "Відкрити налаштування",
|
||||
"ui.open_recent": "Відкрити недавні",
|
||||
"ui.operation": "Операція",
|
||||
@@ -1315,6 +1416,8 @@
|
||||
"ui.radial_context_menu": "Кругове контекстне меню",
|
||||
"ui.radius": "Радіус",
|
||||
"ui.radius_type": "Тип радіуса",
|
||||
"ui.rail": "Напрямна",
|
||||
"ui.rail_fit": "Підгонка за напрямною",
|
||||
"ui.reachable": "Досяжні",
|
||||
"ui.rectangle": "Прямокутник",
|
||||
"ui.rectangular_pattern": "Прямокутний масив",
|
||||
@@ -1330,6 +1433,8 @@
|
||||
"ui.reset_the_settings_on_this_page": "Скинути налаштування на цій сторінці",
|
||||
"ui.restore": "Відновити",
|
||||
"ui.restore_defaults": "Відновити типові",
|
||||
"ui.reverse_horizontal_pan_direction": "Обернути напрямок горизонтального панорамування",
|
||||
"ui.reverse_horizontal_pan_tip": "Горизонтальна прокрутка двома пальцями та бічне колесо панорамують у зворотний бік",
|
||||
"ui.reverse_zoom_direction": "Змінити напрямок масштабування",
|
||||
"ui.revolve": "Обертання",
|
||||
"ui.right": "Справа",
|
||||
@@ -1340,7 +1445,10 @@
|
||||
"ui.save_mesh_pick_bodies": "Виберіть тіла для збереження",
|
||||
"ui.save_mesh_temp_failed": "Не вдалося створити {folder}: {error}",
|
||||
"ui.saved_to": "Збережено {path}",
|
||||
"ui.sbox_pin": "Закріпити вгорі поля S",
|
||||
"ui.sbox_unpin": "Відкріпити",
|
||||
"ui.scale": "Масштаб",
|
||||
"ui.second_rail": "Друга напрямна",
|
||||
"ui.section_analysis": "Аналіз перерізу",
|
||||
"ui.select": "Вибрати",
|
||||
"ui.selectOther": "Вибрати інше…",
|
||||
@@ -1382,8 +1490,34 @@
|
||||
"ui.smooth_wheel_zoom": "Плавне масштабування колесом",
|
||||
"ui.smooth_wheel_zoom_tip": "Вимкніть, щоб застосовувати кожну подію колеса негайно, без плавного завершення",
|
||||
"ui.snap": "Прив'язка",
|
||||
"ui.space_mouse_calibrate": "Калібрувати…",
|
||||
"ui.space_mouse_calibrate_tip": "Рухайте ковпачок так, як просить вікно, щоб SolidCraft дізнався, куди й наскільки далеко рухається кожна вісь цієї 3D-миші. Зберігається для цієї 3D-миші.",
|
||||
"ui.space_mouse_calibrate_title": "Калібрування 3D-миші",
|
||||
"ui.space_mouse_calibrated": "Відкалібровано",
|
||||
"ui.space_mouse_calibration": "Калібрування 3D-миші",
|
||||
"ui.space_mouse_calibration_done": "Готово. Збережіть його, щоб воно застосовувалося щоразу, коли підключено цю 3D-мишу.",
|
||||
"ui.space_mouse_calibration_no_response": "Одна вісь майже не зрушила, тому її не можна відкалібрувати. Почніть спочатку й щоразу рухайте ковпачок далі.",
|
||||
"ui.space_mouse_dead_zone": "Мертва зона",
|
||||
"ui.space_mouse_dead_zone_tip": "Наскільки треба зсунути ковпачок, перш ніж зрушить вигляд, у частці від повного ходу. Збільште, якщо вигляд зміщується, коли ковпачок у спокої.",
|
||||
"ui.space_mouse_forget": "Забути калібрування",
|
||||
"ui.space_mouse_hint": "Переміщуйте й обертайте вид разом; в ескізах доступні лише панорамування та масштаб.",
|
||||
"ui.space_mouse_navigate": "Навігація за допомогою SpaceNavigator / SpaceMouse",
|
||||
"ui.space_mouse_none": "3D-мишу не знайдено",
|
||||
"ui.space_mouse_not_calibrated": "Не відкалібровано",
|
||||
"ui.space_mouse_reading": "Показники",
|
||||
"ui.space_mouse_step_lift": "Підніміть ковпачок угору, утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_press": "Натисніть ковпачок униз, утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_pull": "Потягніть ковпачок до себе, утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_push": "Штовхніть ковпачок від себе, утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_rest": "Приберіть руку з ковпачка й не торкайтеся його.",
|
||||
"ui.space_mouse_step_slide_left": "Зсуньте ковпачок ліворуч, утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_slide_right": "Зсуньте ковпачок праворуч, утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_tilt_back": "Нахиліть ковпачок назад (передній край угору), утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_tilt_forward": "Нахиліть ковпачок уперед (передній край униз), утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_tilt_left": "Нахиліть ковпачок ліворуч (лівий край униз), утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_tilt_right": "Нахиліть ковпачок праворуч (правий край униз), утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_twist_clockwise": "Поверніть ковпачок за годинниковою стрілкою, утримайте й відпустіть.",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "Поверніть ковпачок проти годинникової стрілки, утримайте й відпустіть.",
|
||||
"ui.spacing": "Крок",
|
||||
"ui.spacing_2": "Крок 2",
|
||||
"ui.sphere": "Куля",
|
||||
@@ -1391,7 +1525,9 @@
|
||||
"ui.split_body": "Розділити тіло",
|
||||
"ui.stand_up_tip": "Після відкриття файлу STEP або IGES з напрямком Z вгору пропонувати поворот на чверть оберту навколо X",
|
||||
"ui.start": "Початок",
|
||||
"ui.start_over": "Почати спочатку",
|
||||
"ui.start_radius": "Початковий радіус",
|
||||
"ui.stretch": "Розтягнути",
|
||||
"ui.supersampling": "Наддискретизація (SSAA)",
|
||||
"ui.supersampling_adaptive": "Лише коли вид нерухомий",
|
||||
"ui.supersampling_off": "Вимкнено (1×)",
|
||||
@@ -1410,6 +1546,8 @@
|
||||
"ui.theme": "Тема",
|
||||
"ui.thickness": "Товщина",
|
||||
"ui.thread": "Різьба",
|
||||
"ui.tie_ends": "Зв'язати",
|
||||
"ui.tie_ends_tip": "Обмежити всі незв'язані кінці",
|
||||
"ui.timeline_bottom": "Часовий ланцюжок знизу",
|
||||
"ui.timeline_left_note": "Ліве компонування показує смугу відкату над браузером деталей і сцени.",
|
||||
"ui.timeline_left_sidebar": "Ліва бічна панель",
|
||||
@@ -1432,6 +1570,7 @@
|
||||
"ui.units_of_new_designs": "Одиниці нових проєктів",
|
||||
"ui.unlock": "Розблокувати",
|
||||
"ui.unsuppress": "Скасувати придушення",
|
||||
"ui.untied_ends_tip": "Торкається лише за положенням",
|
||||
"ui.untitled": "Без назви",
|
||||
"ui.up_axis_note": "Яка вісь спрямована вгору у видах (вид осі, головний, наведення). Проєкти не змінюються.",
|
||||
"ui.updateToCurrentView": "Оновити до поточного виду",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"Arc length": "弧长",
|
||||
"Area": "面积",
|
||||
"Attachment": "附着",
|
||||
"BLEND SURFACE": "混合曲面",
|
||||
"BOUNDARY SURFACE": "边界曲面",
|
||||
"BROWSER": "浏览器",
|
||||
"Bend Line Position": "弯折线位置",
|
||||
@@ -79,6 +80,7 @@
|
||||
"Current orthographic view (1:1 mm)": "当前正交视图(1:1毫米)",
|
||||
"Current perspective view": "当前透视视图",
|
||||
"Curvature (G2)": "曲率 (G2)",
|
||||
"Curvature change (G3)": "曲率变化 (G3)",
|
||||
"Customize Toolbar": "自定义工具栏",
|
||||
"Delete all": "全部删除",
|
||||
"Delete rule": "删除规则",
|
||||
@@ -100,6 +102,8 @@
|
||||
"Edit Profile Sketch": "编辑轮廓草图",
|
||||
"Enable rule": "启用规则",
|
||||
"Enabled": "已启用",
|
||||
"End bulge": "终止鼓度",
|
||||
"End continuity": "终止连续性",
|
||||
"Explode": "爆炸",
|
||||
"Explode Text": "分解文字",
|
||||
"Export Animation…": "导出动画…",
|
||||
@@ -107,6 +111,7 @@
|
||||
"Export Current View as DXF…": "将当前视图导出为DXF…",
|
||||
"Export DXF…": "导出DXF…",
|
||||
"Export hidden edges as dashed lines": "以虚线导出隐藏线",
|
||||
"Extend to meet": "延伸至相交",
|
||||
"Extents": "范围",
|
||||
"Face": "面",
|
||||
"Face attachment lost": "面附着丢失",
|
||||
@@ -121,6 +126,7 @@
|
||||
"Follow camera (Look At sketches)": "跟随相机(注视草图)",
|
||||
"Follow the camera (Look At sketches)": "跟随相机(注视草图)",
|
||||
"Font": "字体",
|
||||
"From edges": "起始边",
|
||||
"Full Length": "全长",
|
||||
"Grid": "网格",
|
||||
"Height": "高度",
|
||||
@@ -135,7 +141,9 @@
|
||||
"Interferes": "发生干涉",
|
||||
"Issue title": "Issue 标题",
|
||||
"Joint Origin1": "接头原点1",
|
||||
"Keep (points on it)": "保留(其上的点)",
|
||||
"Keep Tools": "保留工具",
|
||||
"Keep of the other surface": "另一曲面保留的部分",
|
||||
"Later": "稍后",
|
||||
"Leave it": "保持原样",
|
||||
"Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "旧版面附着。重新绑定到经验证的面可创建精确附着。",
|
||||
@@ -173,6 +181,7 @@
|
||||
"Open GitHub draft": "打开 GitHub 草稿",
|
||||
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "使用 Rust 编写的开源参数化 3D CAD。基于 MIT OR Apache-2.0 许可。",
|
||||
"Other side": "另一侧",
|
||||
"Other surface (mutual trim)": "另一曲面(相互修剪)",
|
||||
"Outside": "外侧",
|
||||
"Overall length": "总长",
|
||||
"Parameter values": "参数值",
|
||||
@@ -258,12 +267,18 @@
|
||||
"Stand it up": "将其立起",
|
||||
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "将其立起:绕 X 轴旋转四分之一周,作为一个可撤销的移动特征。",
|
||||
"Stand this part up?": "立起此零件?",
|
||||
"Start bulge": "起始鼓度",
|
||||
"Start continuity": "起始连续性",
|
||||
"Step": "步骤",
|
||||
"Steps": "步数",
|
||||
"Stitch the kept pieces": "缝合保留的部分",
|
||||
"Stitch to both bodies": "缝合到两个实体",
|
||||
"Study": "研究",
|
||||
"Style": "样式",
|
||||
"Suppressed": "已抑制",
|
||||
"Surface": "曲面",
|
||||
"Swap Ends": "交换端点",
|
||||
"TRIM": "修剪",
|
||||
"Tangent (G1)": "相切 (G1)",
|
||||
"Tangent Chain": "相切链",
|
||||
"Tear": "撕裂",
|
||||
@@ -279,8 +294,10 @@
|
||||
"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.": "此面无法提供持久附着。请选择其他面或显式使用固定平面。",
|
||||
"To Offset": "偏移",
|
||||
"To edges": "终止边",
|
||||
"Tolerance": "公差",
|
||||
"Too close": "过近",
|
||||
"Trimming tool (plane, face or curves)": "修剪工具(平面、面或曲线)",
|
||||
"Turn off section analysis before exporting the view.": "请关闭截面分析后再导出视图。",
|
||||
"Type": "类型",
|
||||
"UNTRIM": "取消修剪",
|
||||
@@ -325,6 +342,7 @@
|
||||
"cmd.appearance_assign": "外观",
|
||||
"cmd.appearance_library": "外观库",
|
||||
"cmd.appearance_list": "列出外观",
|
||||
"cmd.assembly_bom": "物料清单",
|
||||
"cmd.body_rename": "重命名实体",
|
||||
"cmd.browser_group": "新建组",
|
||||
"cmd.browser_move": "移动浏览器项目",
|
||||
@@ -346,10 +364,13 @@
|
||||
"cmd.component_insert_design": "插入设计",
|
||||
"cmd.component_links": "列出链接",
|
||||
"cmd.component_list": "列出组件",
|
||||
"cmd.component_metadata": "零件属性",
|
||||
"cmd.component_metadata_set": "更改零件属性",
|
||||
"cmd.component_move_bodies": "将实体移至组件",
|
||||
"cmd.component_move_sketches": "将草图移至组件",
|
||||
"cmd.component_paste_new": "粘贴为新对象",
|
||||
"cmd.component_properties": "组件物理属性",
|
||||
"cmd.component_relink": "重新链接源文件",
|
||||
"cmd.component_rename": "重命名组件",
|
||||
"cmd.component_revert_position": "恢复位置",
|
||||
"cmd.component_unlock": "解锁内容",
|
||||
@@ -401,6 +422,7 @@
|
||||
"cmd.edit_checkpoint": "检查点",
|
||||
"cmd.edit_restore_checkpoint": "恢复检查点",
|
||||
"cmd.engine_commands": "列出命令",
|
||||
"cmd.engine_describe": "描述命令",
|
||||
"cmd.explode_create": "爆炸图",
|
||||
"cmd.explode_delete": "删除爆炸图",
|
||||
"cmd.explode_list": "列出爆炸图",
|
||||
@@ -433,6 +455,7 @@
|
||||
"cmd.joint_drive": "驱动接头",
|
||||
"cmd.joint_edit": "编辑接头",
|
||||
"cmd.joint_gear": "齿轮",
|
||||
"cmd.joint_inspect": "检查接头",
|
||||
"cmd.joint_limits": "接头限制",
|
||||
"cmd.joint_list": "列出接头",
|
||||
"cmd.joint_motion_link": "运动关联",
|
||||
@@ -442,6 +465,7 @@
|
||||
"cmd.joint_rigid_group": "刚性组",
|
||||
"cmd.joint_screw": "螺旋",
|
||||
"cmd.joint_solve": "解算接头",
|
||||
"cmd.joint_sweep": "扫掠接头",
|
||||
"cmd.library_apply": "应用本地库条目",
|
||||
"cmd.library_export": "导出设计库",
|
||||
"cmd.library_inspect": "检查本地库",
|
||||
@@ -590,6 +614,7 @@
|
||||
"cmd.sketch_spline_fit_point": "拟合点样条曲线",
|
||||
"cmd.sketch_spun_profile": "旋转轮廓",
|
||||
"cmd.sketch_toggle_driven": "切换被驱动尺寸",
|
||||
"cmd.sketch_trim_path": "沿路径修剪",
|
||||
"cmd.solid_align": "对齐",
|
||||
"cmd.solid_boundary_fill": "边界填充",
|
||||
"cmd.solid_bounding_solid": "包围实体",
|
||||
@@ -632,8 +657,10 @@
|
||||
"cmd.timeline_roll_to": "滚动历史标记",
|
||||
"cmd.timeline_rollback": "移动时间线标记",
|
||||
"cmd.timeline_suppress": "抑制特征",
|
||||
"cmd.view_ambient_occlusion": "环境光遮蔽",
|
||||
"cmd.view_apply": "还原命名视图",
|
||||
"cmd.view_delete": "删除命名视图",
|
||||
"cmd.view_graphics_preset": "图形预设",
|
||||
"cmd.view_list": "列出命名视图",
|
||||
"cmd.view_navigate": "导航视图",
|
||||
"cmd.view_rename": "重命名命名视图",
|
||||
@@ -716,6 +743,15 @@
|
||||
"joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。",
|
||||
"joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。",
|
||||
"joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。",
|
||||
"messages.ago_h": "{n} 小时前",
|
||||
"messages.ago_min": "{n} 分钟前",
|
||||
"messages.ago_s": "{n} 秒前",
|
||||
"messages.chip_hint": "点击查看本次会话的所有消息",
|
||||
"messages.clear": "清除",
|
||||
"messages.copy_all": "全部复制",
|
||||
"messages.menu": "消息…",
|
||||
"messages.none": "暂无消息。",
|
||||
"messages.title": "消息",
|
||||
"motion.checked_to": "仅检查到第 {step} 步:检查已用完其工作量上限",
|
||||
"motion.collision": "{a} · {b}:第 {steps} 步",
|
||||
"motion.duration_hint": "秒(留空:按步)",
|
||||
@@ -857,7 +893,7 @@
|
||||
"ui.agent_chat_approval": "允许智能体执行此操作吗?",
|
||||
"ui.agent_chat_attach_view": "附加视图",
|
||||
"ui.agent_chat_attach_view_tip": "随消息发送当前视图的图片",
|
||||
"ui.agent_chat_attachable": "图片、文本和 CAD 文件",
|
||||
"ui.agent_chat_attachable": "图片、PDF、文本和 CAD 文件",
|
||||
"ui.agent_chat_auth_agent": "使用 {name} 登录",
|
||||
"ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 SolidCraft。",
|
||||
"ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;SolidCraft 从不接触你的密码或密钥。",
|
||||
@@ -889,7 +925,6 @@
|
||||
"ui.agent_chat_file_error": "{name}:{error}",
|
||||
"ui.agent_chat_file_heic": "{name}:这里无法读取 HEIC 图片;请先导出为 JPEG 或 PNG",
|
||||
"ui.agent_chat_file_importing": "正在导入设计…",
|
||||
"ui.agent_chat_file_kinds": "{name}:可以附加图片(PNG、JPEG、GIF、WebP、BMP、TIFF)、文本文件(包括 SVG)和 CAD 文件(STEP、IGES、FreeCAD、Rhino、STL、3MF、OBJ、glTF)",
|
||||
"ui.agent_chat_file_loading": "正在读取文件…",
|
||||
"ui.agent_chat_file_not_a_file": "{name}:不是文件",
|
||||
"ui.agent_chat_file_not_a_picture": "{name}:不是图片",
|
||||
@@ -1036,6 +1071,8 @@
|
||||
"ui.ai_toolbar_button_note": "工具栏右端的 AI 按钮。Ctrl+Shift+A 和视图菜单仍可打开面板。",
|
||||
"ui.ai_undo_tip": "将智能体为此消息所做的全部更改一步撤销",
|
||||
"ui.all": "全部",
|
||||
"ui.ambient_occlusion": "环境光遮蔽",
|
||||
"ui.ambient_occlusion_help": "在不透明表面相接处添加柔和阴影。需要 GPU 视口。",
|
||||
"ui.analysis": "分析",
|
||||
"ui.angle": "角度",
|
||||
"ui.angle_decimals": "角度小数位",
|
||||
@@ -1050,6 +1087,7 @@
|
||||
"ui.autosave_every": "自动保存间隔",
|
||||
"ui.axes": "轴",
|
||||
"ui.axis": "轴",
|
||||
"ui.back": "返回",
|
||||
"ui.base_point": "基点",
|
||||
"ui.blocked": "被遮挡",
|
||||
"ui.bodies": "实体",
|
||||
@@ -1196,6 +1234,11 @@
|
||||
"ui.fully_constrained": "完全约束",
|
||||
"ui.general": "常规",
|
||||
"ui.graphics": "图形",
|
||||
"ui.graphics_custom": "自定义",
|
||||
"ui.graphics_performance": "性能",
|
||||
"ui.graphics_preset": "图形预设",
|
||||
"ui.graphics_preset_help": "质量:环境光遮蔽、精细显示质量和自适应2×超采样。性能:关闭环境光遮蔽、普通显示质量和关闭超采样。手动修改显示为自定义。MSAA保持不变。",
|
||||
"ui.graphics_quality": "质量",
|
||||
"ui.grid_follows_view": "网格跟随视图",
|
||||
"ui.grid_follows_view_hint": "在侧视图中,将网格显示在朝向该视图的竖直平面上,而不是地面上",
|
||||
"ui.ground_shadow": "地面阴影",
|
||||
@@ -1298,6 +1341,8 @@
|
||||
"ui.loft": "放样",
|
||||
"ui.look_at": "朝向",
|
||||
"ui.look_at_anim_desc": "轴视图、注视、主视图与适合窗口",
|
||||
"ui.manipulator_free_alt": "操纵器自由拖动也适用于 Alt/Option",
|
||||
"ui.manipulator_free_alt_tip": "开:在拉伸/小控件箭头上按 Alt/Option 或 Ctrl/Cmd 可关闭数值捕捉。关:仅 Ctrl/Cmd。",
|
||||
"ui.manufacture": "制造",
|
||||
"ui.material_review.axes": "材料局部坐标轴;不指定实体方向。",
|
||||
"ui.material_review.claims": "来源和许可证声明未经核实。缺失值保持未知。",
|
||||
@@ -1337,6 +1382,7 @@
|
||||
"ui.newSketchGroup": "新建草图组",
|
||||
"ui.new_body": "新建实体",
|
||||
"ui.new_design": "新建设计",
|
||||
"ui.no_geometry_yet": "尚无几何图形",
|
||||
"ui.no_slicer_found": "未找到切片软件",
|
||||
"ui.normal": "法向",
|
||||
"ui.normalConstruction": "普通 / 构造",
|
||||
@@ -1364,6 +1410,7 @@
|
||||
"ui.pan_zoom_orbit": "平移、缩放、环绕",
|
||||
"ui.parallel": "平行",
|
||||
"ui.parameters": "参数",
|
||||
"ui.part_size_shared": "重新生成零件并替换额外添加的特征。此零部件的所有实例都会更新。",
|
||||
"ui.paste": "粘贴",
|
||||
"ui.path": "路径",
|
||||
"ui.pattern_on_path": "路径阵列",
|
||||
@@ -1375,6 +1422,12 @@
|
||||
"ui.placement": "放置",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "角度平面",
|
||||
"ui.plugins": "插件",
|
||||
"ui.plugins_desktop": "插件仅在桌面版中可用。",
|
||||
"ui.plugins_empty": "尚未安装插件。",
|
||||
"ui.plugins_note": "每个插件是 {path} 里的一个文件夹,其中有 plugin.json。SolidCraft 只读取该文件,不运行文件夹里的代码。关闭插件仍会保留安装。",
|
||||
"ui.plugins_skipped": "有 {count} 个文件夹无法读取。",
|
||||
"ui.plugins_version": "{version}",
|
||||
"ui.point": "点",
|
||||
"ui.point_under_the_cursor": "光标下的点",
|
||||
"ui.points": "点",
|
||||
@@ -1400,6 +1453,11 @@
|
||||
"ui.rectangle": "矩形",
|
||||
"ui.rectangular_pattern": "矩形阵列",
|
||||
"ui.redo": "重做",
|
||||
"ui.relink_design_changed": "选择源文件时装配已更改。请重新开始链接。",
|
||||
"ui.relink_done": "已为所有实例重新链接源文件。",
|
||||
"ui.relink_not_linked": "此组件未链接到源设计。",
|
||||
"ui.relink_select_one": "请先选择一个链接组件或其实例。",
|
||||
"ui.relink_source_all": "重新链接源文件(所有实例)…",
|
||||
"ui.removeFromToolbar": "从工具栏移除",
|
||||
"ui.removeSection": "移除剖面",
|
||||
"ui.render": "渲染",
|
||||
@@ -1423,6 +1481,8 @@
|
||||
"ui.save_mesh_pick_bodies": "请选择要保存的实体",
|
||||
"ui.save_mesh_temp_failed": "无法创建 {folder}:{error}",
|
||||
"ui.saved_to": "已保存:{path}",
|
||||
"ui.sbox_pin": "固定到 S 框顶部",
|
||||
"ui.sbox_unpin": "取消固定",
|
||||
"ui.scale": "缩放",
|
||||
"ui.second_rail": "第二导轨",
|
||||
"ui.section_analysis": "截面分析",
|
||||
@@ -1431,6 +1491,9 @@
|
||||
"ui.selectTangentChain": "选择相切链",
|
||||
"ui.select_other_hint": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。",
|
||||
"ui.selection": "选择",
|
||||
"ui.selection_modifiers": "选择修饰键",
|
||||
"ui.selection_shift_add_only": "Shift+点击仅添加、不取消选择",
|
||||
"ui.selection_shift_add_only_tip": "开:Shift 添加,Ctrl/Cmd 切换。关(默认):Shift 与 Ctrl/Cmd 均可切换。",
|
||||
"ui.send": "发送",
|
||||
"ui.send_to_slicer": "发送到切片软件",
|
||||
"ui.sent_to_slicer": "已发送到 {slicer}",
|
||||
@@ -1455,6 +1518,7 @@
|
||||
"ui.sketch": "草图",
|
||||
"ui.sketch_dimension": "草图尺寸",
|
||||
"ui.sketch_grid": "草图网格",
|
||||
"ui.sketch_modifiers": "草图修饰键",
|
||||
"ui.sketch_scale": "草图比例",
|
||||
"ui.sketch_status": "草图状态",
|
||||
"ui.sketches": "草图",
|
||||
@@ -1466,8 +1530,34 @@
|
||||
"ui.smooth_wheel_zoom": "平滑滚轮缩放",
|
||||
"ui.smooth_wheel_zoom_tip": "关闭后每个滚轮事件立即生效,不做平滑延迟",
|
||||
"ui.snap": "捕捉",
|
||||
"ui.space_mouse_calibrate": "校准…",
|
||||
"ui.space_mouse_calibrate_tip": "按窗口提示移动摇杆帽,SolidCraft 会记住这个 3D 鼠标每个轴的方向和行程。校准结果按此 3D 鼠标保存。",
|
||||
"ui.space_mouse_calibrate_title": "校准 3D 鼠标",
|
||||
"ui.space_mouse_calibrated": "已校准",
|
||||
"ui.space_mouse_calibration": "3D 鼠标校准",
|
||||
"ui.space_mouse_calibration_done": "完成。保存后,每次连接此 3D 鼠标都会使用它。",
|
||||
"ui.space_mouse_calibration_no_response": "有一个轴几乎没有移动,因此无法校准。请重新开始,并且每次把摇杆帽移得更远。",
|
||||
"ui.space_mouse_dead_zone": "死区",
|
||||
"ui.space_mouse_dead_zone_tip": "视图开始移动前摇杆帽需要移动的距离,以其全行程的比例表示。如果摇杆帽静止时视图仍在漂移,请调大。",
|
||||
"ui.space_mouse_forget": "清除校准",
|
||||
"ui.space_mouse_hint": "同时移动和旋转视图;草图中仅支持平移和缩放。",
|
||||
"ui.space_mouse_navigate": "使用 SpaceNavigator / SpaceMouse 导航",
|
||||
"ui.space_mouse_none": "未找到 3D 鼠标",
|
||||
"ui.space_mouse_not_calibrated": "未校准",
|
||||
"ui.space_mouse_reading": "读数",
|
||||
"ui.space_mouse_step_lift": "向上提起摇杆帽,保持住,然后松开。",
|
||||
"ui.space_mouse_step_press": "向下按压摇杆帽,保持住,然后松开。",
|
||||
"ui.space_mouse_step_pull": "把摇杆帽拉向自己,保持住,然后松开。",
|
||||
"ui.space_mouse_step_push": "把摇杆帽推离自己,保持住,然后松开。",
|
||||
"ui.space_mouse_step_rest": "请把手从摇杆帽上移开,让它保持静止。",
|
||||
"ui.space_mouse_step_slide_left": "向左推动摇杆帽,保持住,然后松开。",
|
||||
"ui.space_mouse_step_slide_right": "向右推动摇杆帽,保持住,然后松开。",
|
||||
"ui.space_mouse_step_tilt_back": "向后倾斜摇杆帽(前缘向上),保持住,然后松开。",
|
||||
"ui.space_mouse_step_tilt_forward": "向前倾斜摇杆帽(前缘向下),保持住,然后松开。",
|
||||
"ui.space_mouse_step_tilt_left": "向左倾斜摇杆帽(左缘向下),保持住,然后松开。",
|
||||
"ui.space_mouse_step_tilt_right": "向右倾斜摇杆帽(右缘向下),保持住,然后松开。",
|
||||
"ui.space_mouse_step_twist_clockwise": "顺时针扭转摇杆帽,保持住,然后松开。",
|
||||
"ui.space_mouse_step_twist_counterclockwise": "逆时针扭转摇杆帽,保持住,然后松开。",
|
||||
"ui.spacing": "间距",
|
||||
"ui.spacing_2": "间距 2",
|
||||
"ui.sphere": "球体",
|
||||
@@ -1475,12 +1565,15 @@
|
||||
"ui.split_body": "分割实体",
|
||||
"ui.stand_up_tip": "当 Z 朝上的 STEP 或 IGES 文件打开后,提示绕 X 轴旋转四分之一周",
|
||||
"ui.start": "开始",
|
||||
"ui.start_over": "重新开始",
|
||||
"ui.start_radius": "起始半径",
|
||||
"ui.stretch": "伸展",
|
||||
"ui.supersampling": "超级采样 (SSAA)",
|
||||
"ui.supersampling_adaptive": "仅在视图静止时",
|
||||
"ui.supersampling_off": "关 (1×)",
|
||||
"ui.suppress": "抑制",
|
||||
"ui.suppress_auto_snap": "绘制/拖动时抑制自动捕捉",
|
||||
"ui.suppress_auto_snap_note": "默认:Ctrl/Cmd 或 Alt/Option 均可抑制草图捕捉。也可改成只认其中一个键。",
|
||||
"ui.sweep": "扫掠",
|
||||
"ui.sweep_follow_direction": "跟随方向",
|
||||
"ui.sweep_keep_as_drawn": "保持绘制方向",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT OR Apache-2.0</project_license>
|
||||
<name>SolidCraft</name>
|
||||
<summary>Parametric 3D CAD in the style of Fusion</summary>
|
||||
<summary>Independent parametric 3D CAD</summary>
|
||||
<developer id="io.github.bherbruck">
|
||||
<name>SolidCraft contributors</name>
|
||||
</developer>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Sample previews
|
||||
|
||||
The editable authority is the built-in command scripts in
|
||||
`crates/engine/src/sample.rs`. The six 240 × 150 PNGs in `exports/` are generated
|
||||
by SolidCraft's thumbnail renderer from those original designs, using the same
|
||||
isometric camera as Recent. They are embedded by `home.rs`, so displaying Home
|
||||
does not evaluate sample geometry or read pixels back from the GPU. Selecting a
|
||||
sample builds its editable design through the normal commands.
|
||||
|
||||
Regenerate after changing a sample script:
|
||||
|
||||
```sh
|
||||
cargo test --profile ci -p solidcraft-ui-egui --lib home::tests::regenerate_sample_thumbnails -- --ignored --exact --nocapture
|
||||
```
|
||||
|
||||
`exports/scripts.json` records SHA-256 hashes of the scripts. The regular tests
|
||||
check the hashes and decode all six pictures with the same bounds as Recent.
|
||||
The generated PNGs and hash file are necessary shipped exports; benchmark
|
||||
screenshots and other review output stay outside the asset tree.
|
||||
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,8 @@
|
||||
[
|
||||
"1911ce16bbcc8a961b013f53f184fa556705779dbebee2d89f7508463f60da15",
|
||||
"394116b0b022dcf8f1bb469f486f9d3443d5e6abb0fb947b6946264f998f4b76",
|
||||
"13f68dd015a1efd9dd9c720d94796048671579de49958821d003be5ae17b9d59",
|
||||
"4a98200a71f7301a2e289ef4df34e3527e5acc4535671b5f955606712583fdb1",
|
||||
"0d5fd0fd99d606215845a625ac56c8899a407527ef289a427424f064872ab25b",
|
||||
"54d29037a7d599a07602e8884a628b52ccbb055a8c870c9b7ce7cc5c7390247a"
|
||||
]
|
||||
@@ -10,6 +10,7 @@ struct U {
|
||||
acc_u: vec4<f32>, // accessibility map axes (xyz, offset)
|
||||
acc_v: vec4<f32>,
|
||||
eye: vec4<f32>, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
|
||||
ao: vec4<f32>, // radius in world units (zero: disabled)
|
||||
// analysis colours (`theme::AnalysisColors`): draft positive, between, negative; reachable,
|
||||
// blocked; curvature flat, middle, tight
|
||||
ana_c: array<vec4<f32>, 8>,
|
||||
@@ -28,6 +29,7 @@ fn near_z(c: vec4<f32>) -> f32 {
|
||||
}
|
||||
|
||||
@group(1) @binding(2) var acc_t: texture_2d<f32>;
|
||||
@group(2) @binding(0) var ao_depth: texture_2d<u32>;
|
||||
|
||||
fn clipped(p: vec3<f32>) -> bool {
|
||||
return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w;
|
||||
@@ -119,7 +121,7 @@ fn solid(i: TOut, front: bool) -> vec4<f32> {
|
||||
let fw = fwidth(ndv);
|
||||
if (clipped(i.wp)) { discard; }
|
||||
if (!front && length(u.clip.xyz) > 0.5) {
|
||||
// The cut face in its body's colour (as Fusion's "from component"), tinted toward the
|
||||
// The cut face in its body's colour ("from component" colouring), tinted toward the
|
||||
// cap colour so it reads as a cut, hatched at 45 degrees on screen.
|
||||
let base = mix(i.c.rgb, u.cap.rgb, 0.35);
|
||||
let h = fract((i.pos.x + i.pos.y) / 9.0);
|
||||
@@ -128,7 +130,7 @@ fn solid(i: TOut, front: bool) -> vec4<f32> {
|
||||
if (u.ana.x > 0.5 && length(i.n) > 0.5) {
|
||||
return analysis(i, k);
|
||||
}
|
||||
let c = shade(i);
|
||||
let c = lit(i, occlusion(i));
|
||||
// Silhouettes (styles with edges): where a curved surface turns away from the eye, a line
|
||||
// about 1.5 px wide, on the visible surface only (depth-correct).
|
||||
if (u.screen.w > 0.5 && length(i.n) > 0.5 && fw > 1e-6) {
|
||||
@@ -138,6 +140,67 @@ fn solid(i: TOut, front: bool) -> vec4<f32> {
|
||||
return c;
|
||||
}
|
||||
|
||||
// A single-sample opaque depth map, independent of the window's MSAA and its UI offsets.
|
||||
// Preserve the depth's bits in R32Uint: GLSL cannot textureLoad a depth texture,
|
||||
// and float colour attachments are not available on every GL adapter.
|
||||
struct AoDepth {
|
||||
@builtin(frag_depth) depth: f32,
|
||||
@location(0) value: u32,
|
||||
};
|
||||
|
||||
@fragment
|
||||
fn fs_ao_depth(i: TOut, @builtin(front_facing) front: bool) -> AoDepth {
|
||||
if (clipped(i.wp) || length(i.n) < 0.5) { discard; }
|
||||
var depth = i.pos.z;
|
||||
if (!front && length(u.clip.xyz) > 0.5) { depth = cap_depth(i.wp, i.pos.z); }
|
||||
return AoDepth(depth, bitcast<u32>(depth));
|
||||
}
|
||||
|
||||
// Deterministic cosine-weighted hemisphere samples. Projecting the sample and a point one
|
||||
// radius toward the eye supplies a depth range in both orthographic and perspective views;
|
||||
// distant foreground geometry cannot cast a dark halo onto a background surface.
|
||||
fn occlusion(i: TOut) -> f32 {
|
||||
let radius = u.ao.x;
|
||||
if (radius <= 0.0 || length(i.n) < 0.5) { return 1.0; }
|
||||
var n = normalize(i.n);
|
||||
if (dot(n, u.back.xyz) < 0.0) { n = -n; }
|
||||
let axis = select(vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(0.0, 1.0, 0.0), abs(n.z) > 0.9);
|
||||
let tangent = normalize(cross(axis, n));
|
||||
let bitangent = cross(n, tangent);
|
||||
let size = vec2<i32>(textureDimensions(ao_depth));
|
||||
// The map is a 1 × 1 stand-in until a frame draws into it (the product preview draws no
|
||||
// prepass): sampling it tells nothing, so leave the surface unoccluded.
|
||||
if (size.x < 2 || size.y < 2) { return 1.0; }
|
||||
// One comparison for both depth directions: depths read as 0 near / 1 far (mirrored when
|
||||
// the buffer runs reversed). The map is cleared to its far plane either way, so a texel
|
||||
// with nothing in it reads as 1 and the `d < 1.0` test below skips it.
|
||||
let flip = reversed();
|
||||
var blocked = 0.0;
|
||||
for (var j = 0u; j < 32u; j = j + 1u) {
|
||||
let f = (f32(j) + 0.5) / 32.0;
|
||||
let angle = f32(j) * 2.39996323;
|
||||
let direction = tangent * (cos(angle) * sqrt(f)) + bitangent * (sin(angle) * sqrt(f)) + n * sqrt(1.0 - f);
|
||||
let reach = radius * (0.2 + 0.8 * fract(f32(j) * 0.61803399 + 0.5));
|
||||
let sample = i.wp + n * (radius * 0.025) + direction * reach;
|
||||
let q = u.vp * vec4<f32>(sample, 1.0);
|
||||
let near = u.vp * vec4<f32>(sample + u.back.xyz * radius, 1.0);
|
||||
if (q.w <= 0.0 || near.w <= 0.0) { continue; }
|
||||
let ndc = q.xyz / q.w;
|
||||
let uv = vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
|
||||
if (any(uv < vec2<f32>(0.0)) || any(uv >= vec2<f32>(1.0)) || ndc.z < 0.0 || ndc.z > 1.0) { continue; }
|
||||
let d0 = bitcast<f32>(textureLoad(ao_depth, vec2<i32>(uv * vec2<f32>(size)), 0).x);
|
||||
let zs = select(ndc.z, 1.0 - ndc.z, flip);
|
||||
let zn = select(near.z / near.w, 1.0 - near.z / near.w, flip);
|
||||
let d = select(d0, 1.0 - d0, flip);
|
||||
let span = max(zs - zn, 1e-7);
|
||||
let delta = (zs - d) / span;
|
||||
if (d < 1.0 && delta > 0.005 && delta < 1.0) {
|
||||
blocked = blocked + smoothstep(0.005, 0.15, delta) * (1.0 - smoothstep(0.5, 1.0, delta));
|
||||
}
|
||||
}
|
||||
return 1.0 - 0.85 * blocked / 32.0;
|
||||
}
|
||||
|
||||
/// How fast the normal turns across the pixel (per mm).
|
||||
fn curvature(i: TOut) -> f32 {
|
||||
let nn = normalize(i.n);
|
||||
@@ -230,6 +293,10 @@ fn fs_tri_all(i: TOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
|
||||
fn shade(i: TOut) -> vec4<f32> {
|
||||
return lit(i, 1.0);
|
||||
}
|
||||
|
||||
fn lit(i: TOut, visibility: f32) -> vec4<f32> {
|
||||
// A zero normal marks flat colour (selection fills, translucent planes).
|
||||
if (length(i.n) < 0.5) {
|
||||
return out_color(i.c);
|
||||
@@ -242,7 +309,7 @@ fn shade(i: TOut) -> vec4<f32> {
|
||||
let fill = max(dot(n, v), 0.0);
|
||||
let h = normalize(key + v);
|
||||
let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25;
|
||||
let k = 0.42 + 0.38 * diff + 0.25 * fill;
|
||||
let k = 0.42 + 0.38 * diff + 0.25 * fill - (0.42 + 0.25 * fill) * (1.0 - visibility);
|
||||
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), i.c.a));
|
||||
}
|
||||
|
||||
@@ -288,10 +355,14 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(
|
||||
let t = ts[vi];
|
||||
let s = ss[vi];
|
||||
let base = select(ca, cb, t > 0.5);
|
||||
let off = nrm * s * w * 0.5 + dir * (t * 2.0 - 1.0) * w * 0.5;
|
||||
// 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);
|
||||
let off = nrm * s * wd * 0.5 + dir * (t * 2.0 - 1.0) * wd * 0.5;
|
||||
var o: LOut;
|
||||
o.pos = vec4<f32>(base.xy + off / half * base.w, base.z, base.w);
|
||||
o.c = c;
|
||||
o.c = vec4<f32>(c.rgb, c.a * (w / wd));
|
||||
o.wp = select(pa, pb, t > 0.5);
|
||||
return o;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ pub(crate) fn remap_sheet_metadata(st: &mut ModelState, names: &[String], transf
|
||||
.filter(|(_, t)| names.contains(t.body.as_str()))
|
||||
.map(|(i, t)| {
|
||||
let point = apply_point(transform, t.axis_point);
|
||||
let axis = apply_vector(transform, t.axis).normalized().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?;
|
||||
let axis = apply_vector(transform, *t.axis).unit().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?;
|
||||
if !point.is_finite() {
|
||||
return Err(DocError::Invalid("invalid transferred thread placement".into()));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Canvases: reference images placed on a plane (Fusion's Insert Canvas), kept in the design.
|
||||
//! Canvases: reference images placed on a plane (Insert Canvas), kept in the design.
|
||||
//! The image bytes travel inside the design file (base64), so it reopens without the original.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -95,6 +95,11 @@ fn remap_geo(
|
||||
}
|
||||
}
|
||||
GeoRef::Face { name: Some(n), .. } | GeoRef::Edge { name: Some(n), .. } => *n = remap_topology(n, ids, bodies),
|
||||
GeoRef::Vertex { edges: Some(names), .. } => {
|
||||
for n in names {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
}
|
||||
GeoRef::ConstructPoint { name } | GeoRef::Axis { name } => mapped_name(name, names),
|
||||
GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1)?,
|
||||
_ => {}
|
||||
|
||||
@@ -823,7 +823,8 @@ impl Document {
|
||||
let stem = |index: usize| names.faces.get(index).map(|name| crate::naming::strip_piece(name));
|
||||
let seed: Vec<usize> =
|
||||
indices.into_iter().filter(|index| stem(*index).is_some_and(|name| name.starts_with(&own) && !name.contains('@'))).collect();
|
||||
let ambiguous = || DocError::Unknown("attached planar face certificate is missing or ambiguous".into());
|
||||
let 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)?;
|
||||
if seed.iter().any(|index| stem(*index) != Some(first)) {
|
||||
return Err(ambiguous());
|
||||
|
||||
@@ -185,6 +185,24 @@ impl Document {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// A face target for messages: its role on its feature where the certificate tells it, else
|
||||
/// its name, and its body (`the top cap of E1 on `Body1``).
|
||||
pub(crate) fn describe_face_target(&self, target: &ConfigFaceTarget) -> String {
|
||||
let feature = self.feature(target.origin_feature).map_or_else(|| format!("F{}", target.origin_feature), |f| f.name.clone());
|
||||
let end = |high: bool| if high { "top" } else { "bottom" };
|
||||
let role = match &target.certificate {
|
||||
Some(super::FaceCertificate::ExtrudeCap { high, .. }) | Some(super::FaceCertificate::CylinderCap { high }) => {
|
||||
format!("the {} cap", end(*high))
|
||||
}
|
||||
Some(super::FaceCertificate::Box { axis, high }) => {
|
||||
format!("the {}{} side", if *high { "+" } else { "-" }, ["x", "y", "z"].get(usize::from(*axis)).copied().unwrap_or("?"))
|
||||
}
|
||||
Some(super::FaceCertificate::ExtrudeSide { curve, .. }) => format!("the side along `{curve}`"),
|
||||
_ => format!("face `{}`", target.name),
|
||||
};
|
||||
format!("{role} of {feature} on `{}`", target.body)
|
||||
}
|
||||
|
||||
/// Verify the current named pieces describe one surface, not ambiguous generated roles.
|
||||
pub fn configuration_face_indices(&self, body: &crate::ModelBody, target: &ConfigFaceTarget) -> Result<Vec<usize>> {
|
||||
self.certify_config_face(target)?;
|
||||
@@ -248,7 +266,7 @@ impl Document {
|
||||
})
|
||||
.collect();
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into()));
|
||||
return Err(DocError::Invalid(format!("{} no longer exists", self.describe_face_target(target))));
|
||||
}
|
||||
return self.certified_pieces(body, indices, target.origin_feature);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Construction geometry from the model: references to vertices, edges, faces, planes, axes,
|
||||
//! points and sketch entities, resolved on each evaluation, and the axes, points and planes
|
||||
//! built from them (Fusion's CONSTRUCT panel).
|
||||
//! built from them (the CONSTRUCT panel).
|
||||
//!
|
||||
//! Face and edge references carry the persistent name of what was picked (see `naming`), so
|
||||
//! they follow the model when earlier features change; the picked point is the fallback.
|
||||
//! Face and edge references carry the persistent name of what was picked (see `naming`), and a
|
||||
//! vertex the names of the edges that meet there, so they follow the model when earlier features
|
||||
//! change; the picked point is the fallback.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -23,8 +24,13 @@ pub enum GeoRef {
|
||||
Point { p: Vec3 },
|
||||
/// The design origin.
|
||||
Origin,
|
||||
/// A body vertex (the one nearest `at`).
|
||||
Vertex { at: Vec3 },
|
||||
/// A body vertex: where the edges named `edges` meet (the end they share), else the one
|
||||
/// nearest `at`.
|
||||
Vertex {
|
||||
at: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
edges: Option<Vec<String>>,
|
||||
},
|
||||
/// A body edge: the one named `name`, else the one nearest `at`.
|
||||
Edge {
|
||||
at: Vec3,
|
||||
@@ -86,14 +92,14 @@ pub enum PointDef {
|
||||
}
|
||||
|
||||
/// Resolved construction geometry, kept in the model state by feature.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ConstructGeom {
|
||||
Plane(Plane),
|
||||
Axis { origin: Vec3, dir: Vec3 },
|
||||
Point(Vec3),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Construct {
|
||||
pub feature: u64,
|
||||
pub name: String,
|
||||
@@ -170,6 +176,33 @@ fn edge_points(st: &ModelState, at: Vec3, name: &Option<String>) -> Result<Vec<V
|
||||
}
|
||||
}
|
||||
|
||||
/// The most edge names a vertex reference keeps.
|
||||
const MAX_VERTEX_EDGES: usize = 16;
|
||||
|
||||
/// Where the edges named `names` meet: the end every one of them has, nearest `at` (two edges
|
||||
/// between the same two vertices share both). `None` when an edge is gone or they share no end.
|
||||
fn named_vertex(st: &ModelState, at: Vec3, names: &[String]) -> Option<Vec3> {
|
||||
if names.len() < 2 || names.len() > MAX_VERTEX_EDGES {
|
||||
return None;
|
||||
}
|
||||
let ends: Vec<[Vec3; 2]> = names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
// The edge with that name itself, never the one nearest the old pick.
|
||||
let on = bodies(st).find_map(|b| crate::naming::find_edge(b, n, at).ok())?.0;
|
||||
let pts = edge_points(st, on, &None).ok()?;
|
||||
Some([*pts.first()?, *pts.last()?])
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let (first, rest) = ends.split_first()?;
|
||||
let tol = |p: Vec3| 1e-6 * (1.0 + p.len());
|
||||
first
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| p.is_finite() && rest.iter().all(|e| e.iter().any(|q| q.dist(*p) <= tol(*p))))
|
||||
.min_by(|p, q| p.dist(at).total_cmp(&q.dist(at)))
|
||||
}
|
||||
|
||||
/// A polyline that is straight: its line.
|
||||
fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
|
||||
let (a, b) = (*pts.first()?, *pts.last()?);
|
||||
@@ -228,7 +261,10 @@ pub(crate) fn point(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
|
||||
match g {
|
||||
GeoRef::Point { p } => Ok(*p),
|
||||
GeoRef::Origin => Ok(Vec3::ZERO),
|
||||
GeoRef::Vertex { at } => {
|
||||
GeoRef::Vertex { at, edges } => {
|
||||
if let Some(v) = edges.as_deref().and_then(|names| named_vertex(ctx.st, *at, names)) {
|
||||
return Ok(v);
|
||||
}
|
||||
let mut best: Option<(f64, Vec3)> = None;
|
||||
for b in bodies(ctx.st) {
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||||
@@ -285,9 +321,9 @@ pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> {
|
||||
GeoRef::Face { at, name } => {
|
||||
let (b, fi, _) = face_of(ctx.st, *at, name)?;
|
||||
match kernel::face_surface(&b.body, fi) {
|
||||
Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, axis)),
|
||||
Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, axis)),
|
||||
Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, axis)),
|
||||
Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, *axis)),
|
||||
Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, *axis)),
|
||||
Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, *axis)),
|
||||
_ => Err(bad("the face is not a cylinder, cone or torus")),
|
||||
}
|
||||
}
|
||||
@@ -367,7 +403,7 @@ fn path_points(ctx: &Ctx, g: &GeoRef) -> Result<Vec<Vec3>> {
|
||||
/// The surface's unit normal at (the point of the surface nearest) `p`, and that point.
|
||||
fn surface_normal(s: FaceSurface, p: Vec3) -> Option<(Vec3, Vec3)> {
|
||||
match s {
|
||||
FaceSurface::Plane { normal, point } => Some((p - normal * (p - point).dot(normal), normal)),
|
||||
FaceSurface::Plane { normal, point } => Some((p - normal * (p - point).dot(normal), *normal)),
|
||||
FaceSurface::Cylinder { origin, axis, radius } => {
|
||||
let w = p - origin;
|
||||
let r = (w - axis * w.dot(axis)).normalized()?;
|
||||
@@ -403,7 +439,7 @@ fn face_surface_at(ctx: &Ctx, g: &GeoRef) -> Result<(FaceSurface, Vec3)> {
|
||||
}
|
||||
GeoRef::Plane { .. } => {
|
||||
let pl = plane(ctx, g)?;
|
||||
Ok((FaceSurface::Plane { normal: pl.normal(), point: pl.origin }, pl.origin))
|
||||
Ok((FaceSurface::Plane { normal: pl.normal().unit().ok_or_else(|| bad("degenerate plane"))?, point: pl.origin }, pl.origin))
|
||||
}
|
||||
_ => Err(bad("pick a face")),
|
||||
}
|
||||
@@ -553,13 +589,14 @@ impl GeoRef {
|
||||
pub fn map_points(&mut self, pt: &mut dyn FnMut(&mut Vec3), plane: &mut dyn FnMut(&mut PlaneRef)) {
|
||||
match self {
|
||||
GeoRef::Point { p } => pt(p),
|
||||
GeoRef::Vertex { at } | GeoRef::Edge { at, .. } | GeoRef::Face { at, .. } => pt(at),
|
||||
GeoRef::Vertex { at, .. } | GeoRef::Edge { at, .. } | GeoRef::Face { at, .. } => pt(at),
|
||||
GeoRef::Plane { plane: p } => plane(p),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Picked faces and edges get their persistent names (and only those still missing one).
|
||||
/// Picked faces and edges get their persistent names, and picked vertices the names of the
|
||||
/// edges that meet there (only those still missing them).
|
||||
pub fn name_picks(&mut self, st: &ModelState) {
|
||||
match self {
|
||||
GeoRef::Face { at, name: name @ None } => {
|
||||
@@ -574,6 +611,24 @@ impl GeoRef {
|
||||
}
|
||||
}
|
||||
}
|
||||
GeoRef::Vertex { at, edges: edges @ None } => {
|
||||
for b in bodies(st) {
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||||
let m = b.mesh();
|
||||
let ends_here = |e: &Vec<Vec3>| [e.first(), e.last()].into_iter().flatten().any(|q| q.dist(*at) <= tol);
|
||||
let mut meet: Vec<String> =
|
||||
m.edges.iter().zip(crate::naming::edge_names(b)).filter(|(e, n)| !n.is_empty() && ends_here(e)).map(|(_, n)| n).collect();
|
||||
if meet.is_empty() {
|
||||
continue;
|
||||
}
|
||||
meet.sort();
|
||||
meet.dedup();
|
||||
if (2..=MAX_VERTEX_EDGES).contains(&meet.len()) {
|
||||
*edges = Some(meet);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
GeoRef::Plane { plane } => plane.name_picks(st),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,9 @@ pub struct Extent {
|
||||
pub convention: ExtrudeConvention,
|
||||
#[serde(default)]
|
||||
pub direction: Direction,
|
||||
/// Optional second side distance (two-sided extrude), along the negative normal.
|
||||
/// Optional second side distance (two-sided extrude): the other way from the first side (against
|
||||
/// the normal, or along it when the first side goes against it: `direction: negative`, a
|
||||
/// negative distance, or a To Object below the sketch).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(length = "Distance 2")]
|
||||
pub distance2: Option<Expr>,
|
||||
@@ -299,8 +301,8 @@ impl SweepOrientation {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sweep's guide rail: a chain of curves of a sketch, followed from the end nearest the path's
|
||||
/// start.
|
||||
/// A sweep's or a loft's guide rail: a chain of curves of a sketch, followed from the end nearest
|
||||
/// the path's start (the loft's first section).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
pub struct SweepRail {
|
||||
pub sketch: SketchId,
|
||||
@@ -740,6 +742,12 @@ pub enum FeatureKind {
|
||||
operation: Operation,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<BodyName>,
|
||||
/// Guide rails (one or two) the loft follows between its sections.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
rails: Vec<SweepRail>,
|
||||
/// How the rails reshape the sections between.
|
||||
#[serde(default, skip_serializing_if = "RailFit::is_scale")]
|
||||
rail_fit: RailFit,
|
||||
},
|
||||
/// Sweep profiles along a chain of sketch curves.
|
||||
Sweep {
|
||||
@@ -812,11 +820,11 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
line: Option<String>,
|
||||
},
|
||||
/// A construction axis (Fusion's CONSTRUCT axes).
|
||||
/// A construction axis (CONSTRUCT axes).
|
||||
ConstructionAxis {
|
||||
def: AxisDef,
|
||||
},
|
||||
/// A construction point (Fusion's CONSTRUCT points).
|
||||
/// A construction point (CONSTRUCT points).
|
||||
ConstructionPoint {
|
||||
def: PointDef,
|
||||
},
|
||||
@@ -1372,6 +1380,12 @@ pub enum FeatureKind {
|
||||
continuity: [Continuity; 2],
|
||||
#[serde(default, skip_serializing_if = "loft_crown_is_plain")]
|
||||
crown: [f64; 2],
|
||||
/// Guide rails (one or two) the loft follows between its sections.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
rails: Vec<SweepRail>,
|
||||
/// How the rails reshape the sections between.
|
||||
#[serde(default, skip_serializing_if = "RailFit::is_scale")]
|
||||
rail_fit: RailFit,
|
||||
},
|
||||
/// The faces at `faces` copied out at a signed `distance` along their normals as a new
|
||||
/// surface body (0 copies them); `chain` takes each pick's whole smooth chain of faces;
|
||||
@@ -1441,6 +1455,11 @@ pub enum FeatureKind {
|
||||
/// Points the blend leaves towards, when the faces at the picks do not say: [from, to].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
align: Option<[Position; 2]>,
|
||||
/// Where each chain's stations start, when two closed chains should not pair the way
|
||||
/// that keeps the blend from twisting: a point on each chain, [from, to]. An open chain
|
||||
/// starts at the end nearer its point.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
seam: Option<[Position; 2]>,
|
||||
/// Sew the blend onto its two bodies where the boundaries meet.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
stitch: bool,
|
||||
@@ -1511,14 +1530,31 @@ pub enum FeatureKind {
|
||||
#[walk(length = "Tolerance")]
|
||||
tolerance: Expr,
|
||||
},
|
||||
/// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of
|
||||
/// `keep`.
|
||||
/// A surface body trimmed by a plane, by a face of a body (`tool`, its surface extended) or
|
||||
/// by sketch curves swept onto it (`curves`), keeping the side of `keep`; with `keep_more`,
|
||||
/// the regions (pieces that hang together once cut) `keep` and those points pick instead.
|
||||
/// With `with`, a mutual trim: `body` and `with` (one face each) cut each other, `with`
|
||||
/// keeping the side of its one `keep_with` point or the regions of several; `extend`
|
||||
/// extends one that stops short of the other to meet it, and `stitch` sews the two kept
|
||||
/// pieces into `body` (`with` is then gone).
|
||||
SurfaceTrim {
|
||||
body: BodyName,
|
||||
plane: PlaneRef,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
tool: Option<FaceAt>,
|
||||
keep: Position,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
keep_more: Vec<Position>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
curves: Option<TrimCurves>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
with: Option<BodyName>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
keep_with: Vec<Position>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
stitch: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
extend: bool,
|
||||
},
|
||||
Move {
|
||||
bodies: Vec<BodyName>,
|
||||
@@ -1689,6 +1725,18 @@ impl FilletStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch curves a trim cuts with: chains of the curves `curves` (every curve of the sketch
|
||||
/// that is not construction geometry when empty) swept along `direction` (the sketch's normal
|
||||
/// when none) onto the surface.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
pub struct TrimCurves {
|
||||
pub sketch: SketchId,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub curves: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub direction: Option<Dir>,
|
||||
}
|
||||
|
||||
/// A face picked by a point on it, on a named body.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
pub struct FaceAt {
|
||||
@@ -1823,13 +1871,21 @@ pub struct SketchPoints {
|
||||
|
||||
/// A component: a node of the design tree that owns sketches, features and bodies. The root
|
||||
/// (id 0) is the design itself and is not stored.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Component {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// Parent component (0 = the root).
|
||||
#[serde(default)]
|
||||
pub parent: u64,
|
||||
/// The definition's part properties (part number, description, revision…) and other tools'
|
||||
/// data, under the same rules as the design's own `metadata`. Every occurrence shares them;
|
||||
/// the bill of materials reads them.
|
||||
#[serde(default, skip_serializing_if = "crate::Metadata::is_empty")]
|
||||
pub metadata: crate::Metadata,
|
||||
/// Free text properties of the definition (material grade, finish, supplier…), by name.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub properties: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// One loft section: profiles of a sketch.
|
||||
@@ -2065,7 +2121,7 @@ impl FeatureKind {
|
||||
)
|
||||
}
|
||||
|
||||
/// Fusion-like type name (timeline tooltip, `inspect`).
|
||||
/// Short type name (timeline tooltip, `inspect`).
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
|
||||
@@ -2593,6 +2649,23 @@ impl Document {
|
||||
*self == normalized
|
||||
}
|
||||
|
||||
/// Only components' part properties (metadata and free properties) changed: nothing to
|
||||
/// evaluate. The same whole-Document proof as [`Document::differs_only_in_render`].
|
||||
pub fn differs_only_in_part_properties(&self, previous: &Document) -> bool {
|
||||
if self.components.len() != previous.components.len()
|
||||
|| self.components.iter().zip(&previous.components).any(|(a, b)| a.id != b.id)
|
||||
|| self.components == previous.components
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut normalized = previous.clone();
|
||||
for (n, c) in normalized.components.iter_mut().zip(&self.components) {
|
||||
n.metadata = c.metadata.clone();
|
||||
n.properties = c.properties.clone();
|
||||
}
|
||||
*self == normalized
|
||||
}
|
||||
|
||||
/// The colour a body is shown and exported in (sRGB as 0–1 fractions): its appearance,
|
||||
/// else its component's, else its material's look, else the colour it was imported with.
|
||||
/// (Faces may have their own: `face_looks`.)
|
||||
@@ -2691,6 +2764,23 @@ impl Document {
|
||||
if body.transferred || body.source_name.is_some() { body.component } else { self.legacy_body_component(&body.name, body.feature) }
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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()
|
||||
}
|
||||
|
||||
/// Recorded transfers already converted geometry into the new local frame.
|
||||
pub fn model_body_offset(&self, body: &crate::ModelBody) -> Option<&crate::Mat> {
|
||||
if body.transferred { None } else { self.body_offsets.get(&body.name) }
|
||||
@@ -2716,7 +2806,7 @@ impl Document {
|
||||
format!("Component{k}")
|
||||
}
|
||||
};
|
||||
self.components.push(Component { id, name, parent });
|
||||
self.components.push(Component { id, name, parent, ..Default::default() });
|
||||
self.add_occurrence(id, parent, crate::IDENTITY)?;
|
||||
Ok(id)
|
||||
}
|
||||
@@ -2843,9 +2933,10 @@ impl Document {
|
||||
/// Insert a feature as given, keeping its id, name, parameter names and stored edge and
|
||||
/// face names, at the marker (or the end), with the model parameters its sketch dimensions
|
||||
/// use: a feature replayed from a script (`timeline.add`) is the feature the design had.
|
||||
/// An id of 0 or an empty name gets a new one. Refused, changing nothing, when the id, the
|
||||
/// name or a parameter name is already used, or the feature belongs to a component this
|
||||
/// design does not have.
|
||||
/// An id of 0 or an empty name gets a new one. The name is kept even when another feature
|
||||
/// has it, as a sketch and the feature built from it often share one (#1990). Refused,
|
||||
/// changing nothing, when the id or a parameter name is already used, or the feature
|
||||
/// belongs to a component this design does not have.
|
||||
pub fn insert_feature(&mut self, mut f: Feature, params: Vec<Param>) -> Result<u64> {
|
||||
if self.features.len() >= MAX_FEATURES {
|
||||
return Err(DocError::Invalid("too many features".into()));
|
||||
@@ -2860,8 +2951,6 @@ impl Document {
|
||||
f.name = f.name.trim().to_string();
|
||||
if f.name.is_empty() {
|
||||
f.name = self.unique_name(f.kind.base_name());
|
||||
} else if self.features.iter().any(|x| x.name == f.name) {
|
||||
return Err(DocError::Invalid(format!("a feature named `{}` already exists", f.name)));
|
||||
}
|
||||
if f.component != 0 && !self.components.iter().any(|c| c.id == f.component) {
|
||||
return Err(DocError::Invalid(format!("feature `{}` belongs to an unknown component", f.name)));
|
||||
@@ -3195,8 +3284,10 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply parameter values to every dimension in a sketch (in place).
|
||||
/// Apply parameter values to every dimension in a sketch, and to its points placed by
|
||||
/// parameters (in place).
|
||||
pub fn apply_dimension_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
|
||||
self.apply_point_values(vals, sk)?;
|
||||
for c in &mut sk.constraints {
|
||||
if let Some(p) = &c.param {
|
||||
let kind = if c.kind.is_angle() { Kind::Angle } else { Kind::Length };
|
||||
@@ -3210,6 +3301,27 @@ impl Document {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move the points placed by parameters to where their expressions come to (in place). Such
|
||||
/// a point is fixed, so the solve keeps it there.
|
||||
pub fn apply_point_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
|
||||
use crate::eval::val;
|
||||
for p in &mut sk.points {
|
||||
let Some(exprs) = p.exprs.as_deref() else { continue };
|
||||
let mut at = p.pos;
|
||||
for (e, slot, what) in [(&exprs.x, &mut at.x, "X"), (&exprs.y, &mut at.y, "Y")] {
|
||||
if let Some(e) = e {
|
||||
*slot = val(vals, e, Kind::Length).map_err(|error| DocError::Expr(format!("point {} {what}: {error}", p.id)))?;
|
||||
}
|
||||
}
|
||||
if !(at.is_finite() && at.x.abs() < solidcraft_sketch::MAX_COORD && at.y.abs() < solidcraft_sketch::MAX_COORD) {
|
||||
return Err(DocError::Invalid(format!("point {} is placed out of range", p.id)));
|
||||
}
|
||||
p.pos = at;
|
||||
p.fixed = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Give the texts of a sketch the numbers their expressions come to (in place): a text's
|
||||
/// height, place and angle that were typed as expressions. A text's outlines are rebuilt from
|
||||
/// its numbers when the sketch's links are refreshed, so this comes before that. A number
|
||||
@@ -3260,20 +3372,42 @@ impl Document {
|
||||
sk: &mut Sketch,
|
||||
stored: &solidcraft_sketch::DrivingValues,
|
||||
moved_from: Option<&Sketch>,
|
||||
) -> Result<solidcraft_sketch::SolveReport> {
|
||||
self.solve_sketch_with_analysis(vals, sk, stored, moved_from, solidcraft_sketch::Analysis::Full)
|
||||
}
|
||||
|
||||
/// [`Self::solve_sketch`] with the diagnostics needed by an internal edit. Parameter
|
||||
/// application, continuation and offset refreshes run regardless of the requested level.
|
||||
/// Model evaluation uses `solve_sketch` so its published report always has full diagnostics.
|
||||
pub fn solve_sketch_with_analysis(
|
||||
&self,
|
||||
vals: &std::collections::BTreeMap<String, expr::Value>,
|
||||
sk: &mut Sketch,
|
||||
stored: &solidcraft_sketch::DrivingValues,
|
||||
moved_from: Option<&Sketch>,
|
||||
analysis: solidcraft_sketch::Analysis,
|
||||
) -> Result<solidcraft_sketch::SolveReport> {
|
||||
self.apply_dimension_values(vals, sk)?;
|
||||
let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from));
|
||||
let mut report = solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref());
|
||||
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
|
||||
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
|
||||
// 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 };
|
||||
let mut report = solidcraft_sketch::solve_after_edit_with_analysis(sk, stored, was(sk).as_deref(), first_analysis);
|
||||
if offsets {
|
||||
// Source dimensions may have moved curves during the solve.
|
||||
sk.refresh_offsets()?;
|
||||
report = if report.ok() { solidcraft_sketch::solve(sk) } else { solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref()) };
|
||||
report = if report.ok() {
|
||||
solidcraft_sketch::solve_with(sk, &solidcraft_sketch::Hold::default(), None, analysis, None)
|
||||
} else {
|
||||
solidcraft_sketch::solve_after_edit_with_analysis(sk, stored, was(sk).as_deref(), analysis)
|
||||
};
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
|
||||
/// Density of the default material (g/cm³), as Fusion's default.
|
||||
/// Density of the default material (g/cm³), as the default.
|
||||
#[path = "../../../assets/materials/library.rs"]
|
||||
mod materials;
|
||||
pub use materials::{DEFAULT_DENSITY, MATERIALS, material_color};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
//! Timeline evaluation.
|
||||
|
||||
#[path = "placed_mesh.rs"]
|
||||
mod placed_mesh;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::tess::TessPlan;
|
||||
use solidcraft_geom::{Mesh, Plane, Region2, Vec2, Vec3, regions_apart};
|
||||
use solidcraft_geom::{Mesh, Plane, Region2, UnitVec3, Vec2, Vec3, regions_apart};
|
||||
use solidcraft_kernel::{self as kernel, Body, BoolOp};
|
||||
use solidcraft_sketch::{CurveKind, Profile, SolveReport, find_profiles};
|
||||
|
||||
@@ -28,6 +31,8 @@ pub struct ModelBody {
|
||||
// Timeline snapshots are cloned before meshes are requested. Share the cell itself so
|
||||
// meshing any snapshot also warms every unchanged copy of this body.
|
||||
mesh: Arc<OnceLock<Arc<Mesh>>>,
|
||||
/// Lazy canonical reference triangles for a proper rigid occurrence placement.
|
||||
mesh_placement: Option<Arc<placed_mesh::PlacedMesh>>,
|
||||
/// The display plan (none when the body has no plan: a mesh body, or one the planner
|
||||
/// refuses), shared like the mesh. A GPU viewport draws it.
|
||||
plan: Arc<[OnceLock<Option<Arc<TessPlan>>>; 4]>,
|
||||
@@ -50,6 +55,11 @@ pub struct ModelBody {
|
||||
/// [`Body::retained_bytes`] of `body`, measured when a feature made or changed it (0: not
|
||||
/// measured), so the evaluation cache's budget does not walk every body after every feature.
|
||||
bytes: usize,
|
||||
/// In a world state: the [`ModelBody::geometry_key`] of the model body this one places (0: none).
|
||||
source_key: u64,
|
||||
/// [`Body::outline_bounds`] of `body` (with the body it was measured on), worked out once
|
||||
/// and shared by clones.
|
||||
outline: Arc<OnceLock<(Body, solidcraft_geom::Aabb3)>>,
|
||||
}
|
||||
|
||||
impl ModelBody {
|
||||
@@ -61,6 +71,7 @@ impl ModelBody {
|
||||
body,
|
||||
feature,
|
||||
mesh: Arc::new(OnceLock::new()),
|
||||
mesh_placement: None,
|
||||
plan: Arc::new(Default::default()),
|
||||
plan_mesh: Arc::new(Default::default()),
|
||||
key: 0,
|
||||
@@ -70,8 +81,34 @@ impl ModelBody {
|
||||
transferred: false,
|
||||
imported: None,
|
||||
bytes: 0,
|
||||
source_key: 0,
|
||||
outline: Arc::new(OnceLock::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A box around the body without meshing it ([`Body::outline_bounds`]), worked out once.
|
||||
pub fn outline_bounds(&self) -> solidcraft_geom::Aabb3 {
|
||||
match self.outline.get_or_init(|| (self.body.clone(), self.body.outline_bounds())) {
|
||||
(measured, b) if measured.same(&self.body) => *b,
|
||||
// The body was replaced in this cell's holder since: measure it again.
|
||||
_ => self.body.outline_bounds(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity of this body's geometry where it stands: the same for a body evaluated or
|
||||
/// placed again from the same inputs at the same place, so work measured on it can be
|
||||
/// reused (none for a body the evaluation cache does not key).
|
||||
pub fn geometry_key(&self) -> Option<u64> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let key = if self.key != 0 { self.key } else { self.source_key };
|
||||
if key == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
key.hash(&mut h);
|
||||
self.source_transform.iter().flatten().for_each(|v| v.to_bits().hash(&mut h));
|
||||
Some(h.finish())
|
||||
}
|
||||
pub fn definition_name(&self) -> &str {
|
||||
self.source_name.as_deref().unwrap_or(&self.name)
|
||||
}
|
||||
@@ -83,9 +120,10 @@ impl ModelBody {
|
||||
// finding their surface, picks naming their face, sketch planes), and a different
|
||||
// triangulation moved those and changed what designs build. A GPU viewport draws the
|
||||
// plan ([`ModelBody::display_plan`]).
|
||||
self.mesh
|
||||
.get_or_init(|| Arc::new(self.body.reference_display_mesh_angular(self.display_tol(), kernel::DISPLAY_ANGLE).unwrap_or_default()))
|
||||
.clone()
|
||||
match self.mesh_placement.as_deref() {
|
||||
Some(placed) => self.mesh.get_or_init(|| placed.mesh()).clone(),
|
||||
None => placed_mesh::reference_mesh(&self.body, &self.mesh, self.display_tol()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The display plan of this body at the viewport's display quality
|
||||
@@ -98,16 +136,12 @@ impl ModelBody {
|
||||
|
||||
/// The display plan at a given display quality, made once per level.
|
||||
pub fn display_plan_at(&self, q: crate::display_quality::DisplayQuality) -> Option<Arc<TessPlan>> {
|
||||
self.plan
|
||||
.get(q.index())?
|
||||
.get_or_init(|| match self.body.display_plan_angular(q.chord(self.body.size()), q.angle()) {
|
||||
Ok(plan) => Some(Arc::new(plan)),
|
||||
Err(e) => {
|
||||
log::debug!("{}: no display plan ({e})", self.name);
|
||||
None
|
||||
}
|
||||
})
|
||||
.clone()
|
||||
let cell = self.plan.get(q.index())?;
|
||||
match self.mesh_placement.as_deref() {
|
||||
// A placed part shows its definition's plan, moved (planned once for every placement).
|
||||
Some(placed) => cell.get_or_init(|| placed.plan(q, &self.name)).clone(),
|
||||
None => placed_mesh::definition_plan(&self.body, cell, q, &self.name),
|
||||
}
|
||||
}
|
||||
|
||||
/// The display plan's mesh, evaluated on the CPU once per level: the same triangles a GPU
|
||||
@@ -122,7 +156,10 @@ impl ModelBody {
|
||||
pub fn plan_mesh_at(&self, q: crate::display_quality::DisplayQuality) -> Option<Arc<Mesh>> {
|
||||
self.plan_mesh
|
||||
.get(q.index())?
|
||||
.get_or_init(|| self.display_plan_at(q).and_then(|plan| self.body.display_mesh_of(&plan).ok()).map(Arc::new))
|
||||
.get_or_init(|| match self.mesh_placement.as_deref() {
|
||||
Some(placed) => placed.plan_mesh(q, &self.name),
|
||||
None => self.display_plan_at(q).and_then(|plan| self.body.display_mesh_of(&plan).ok()).map(Arc::new),
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
@@ -145,9 +182,10 @@ impl ModelBody {
|
||||
Some((Arc::new(plan), Arc::new(mesh)))
|
||||
}
|
||||
|
||||
/// The chord tolerance of [`ModelBody::mesh`] (mm): 1/1000 of the body's size, at least 1 µm.
|
||||
/// The chord tolerance of [`ModelBody::mesh`] (mm): 1/1000 of the definition's size, at least
|
||||
/// 1 µm. Proper rigid placements retain that tolerance along with the reference triangles.
|
||||
pub fn display_tol(&self) -> f64 {
|
||||
(self.body.size() * 1e-3).max(1e-3)
|
||||
self.mesh_placement.as_ref().map(|placed| placed.tolerance).unwrap_or_else(|| (self.body.size() * 1e-3).max(1e-3))
|
||||
}
|
||||
|
||||
/// Already computed display geometry; saving must not trigger cold tessellation.
|
||||
@@ -170,7 +208,7 @@ pub struct SolvedSketch {
|
||||
}
|
||||
|
||||
/// A cosmetic thread: on which cylinder, its size, and the threaded part of the axis.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ThreadInfo {
|
||||
pub feature: u64,
|
||||
pub body: String,
|
||||
@@ -179,7 +217,7 @@ pub struct ThreadInfo {
|
||||
pub pitch: f64,
|
||||
pub internal: bool,
|
||||
pub axis_point: Vec3,
|
||||
pub axis: Vec3,
|
||||
pub axis: UnitVec3,
|
||||
/// Threaded part along the axis from `axis_point`.
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
@@ -356,7 +394,21 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
|
||||
(r_root, p / 8.0, r_out, (p / 4.0 + 2.0 * (r_out - r_root) * slope) / 2.0)
|
||||
};
|
||||
if w0.max(w1) * 2.0 >= p * 0.98 {
|
||||
return Err(DocError::Invalid("the face is too far from the thread's diameter".into()));
|
||||
// The groove would be wider than a pitch: the face is far inside the thread (a hole
|
||||
// narrower than its minor diameter, a shaft thicker than its major one, #2688).
|
||||
let (m, dia) = (&t.designation, 2.0 * radius);
|
||||
let alt = thread_for(dia, t.internal).map(|(n, q)| format_thread(n, q)).filter(|a| a != m);
|
||||
let shown = show_mm(dia);
|
||||
return Err(DocError::Invalid(if t.internal {
|
||||
let alt = alt.map(|a| format!(", or thread this hole {a}")).unwrap_or_default();
|
||||
format!(
|
||||
"this {shown} mm hole is too narrow for a modelled {m} thread (its teeth would fill the hole); drill {} mm for {m}{alt}",
|
||||
show_mm(d - p)
|
||||
)
|
||||
} else {
|
||||
let alt = alt.map(|a| format!(", or thread this shaft {a}")).unwrap_or_default();
|
||||
format!("this {shown} mm shaft is too thick for a modelled {m} thread; make the shaft {} mm for {m}{alt}", show_mm(d))
|
||||
}));
|
||||
}
|
||||
let k = t.axis;
|
||||
let u = k.cross(if k.x.abs() < 0.9 { Vec3::new(1.0, 0.0, 0.0) } else { Vec3::new(0.0, 1.0, 0.0) });
|
||||
@@ -365,10 +417,21 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
|
||||
// at the threaded length's end.
|
||||
let (s0, s1) = (if open.0 { t.start - p } else { t.start }, if open.1 { t.end + p } else { t.end });
|
||||
let base = t.axis_point + k * s0;
|
||||
let plane = Plane { origin: base, x: u, y: k };
|
||||
let plane = Plane { origin: base, x: u, y: *k };
|
||||
let section = [Vec2::new(r0, -w0), Vec2::new(r1, -w1), Vec2::new(r1, w1), Vec2::new(r0, w0)];
|
||||
// Sections 7.5° apart: the chords sag 0.2% of the radius.
|
||||
Ok(kernel::sweep_helix_mesh(&plane, §ion, base, k, p, (s1 - s0) / p, 48)?)
|
||||
// Keep radial chord error below 1% of the pitch, rather than a fixed fraction of the
|
||||
// diameter. At 48 stations a large fine thread cuts too deeply; its outer tool facets
|
||||
// can even dip under the shaft surface. The existing coarse sizes still use 48.
|
||||
let outer = r0.abs().max(r1.abs());
|
||||
if !outer.is_finite() {
|
||||
return Err(DocError::Invalid("thread dimensions are too large".into()));
|
||||
}
|
||||
let angle = (1.0 - (p * 0.01 / outer).min(1.0)).acos();
|
||||
let stations = (std::f64::consts::PI / angle).ceil().max(48.0);
|
||||
if !stations.is_finite() || stations > 720.0 {
|
||||
return Err(DocError::Invalid("the modelled thread is too fine for this diameter; use a cosmetic thread".into()));
|
||||
}
|
||||
Ok(kernel::sweep_helix_mesh(&plane, §ion, base, *k, p, (s1 - s0) / p, stations as usize)?)
|
||||
}
|
||||
|
||||
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
|
||||
@@ -552,7 +615,7 @@ impl ModelState {
|
||||
/// [`quantize`]); the rest keep theirs.
|
||||
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
|
||||
let feature = f.id;
|
||||
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
|
||||
let depth = before.bodies.iter().filter_map(|b| b.names.depth()).max().unwrap_or(0) + 1;
|
||||
let sheets = if f.kind.is_sheet() { self.sheets.clone() } else { Vec::new() };
|
||||
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats, sheets });
|
||||
// Pieces a cut left are named after their faces, not the kernel's shell order (unless
|
||||
@@ -1021,23 +1084,26 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
|
||||
};
|
||||
let wired = |id: &crate::SketchId| doc.sketch(id.0).is_ok_and(|sketch| !sketch.wires.is_empty());
|
||||
let rails: &[crate::SweepRail] = match &f.kind {
|
||||
FeatureKind::Sweep { rails, .. } | FeatureKind::SurfaceSweep { rails, .. } => rails,
|
||||
FeatureKind::Sweep { rails, .. }
|
||||
| FeatureKind::SurfaceSweep { rails, .. }
|
||||
| FeatureKind::Loft { rails, .. }
|
||||
| FeatureKind::SurfaceLoft { rails, .. } => rails,
|
||||
_ => &[],
|
||||
};
|
||||
if let Some(path) = path
|
||||
&& (wired(&path) || rails.iter().any(|r| wired(&r.sketch)))
|
||||
{
|
||||
if path.as_ref().is_some_and(wired) || rails.iter().any(|r| wired(&r.sketch)) {
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
let owner = doc.feature(path.0).map(|feature| feature.component).unwrap_or(0);
|
||||
let owner = path.and_then(|path| doc.feature(path.0)).map(|feature| feature.component).unwrap_or(0);
|
||||
for frame in [doc.component_transform(f.component), doc.component_transform(owner)] {
|
||||
frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||||
}
|
||||
if let FeatureKind::Sweep { sketch, rails, .. } | FeatureKind::SurfaceSweep { sketch, rails, .. } = &f.kind {
|
||||
// The profile's and the rails' components place them too.
|
||||
for sketch in std::iter::once(sketch).chain(rails.iter().map(|r| &r.sketch)) {
|
||||
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
|
||||
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||||
}
|
||||
// A sweep's profile and the rails' components place them too.
|
||||
let profile = match &f.kind {
|
||||
FeatureKind::Sweep { sketch, .. } | FeatureKind::SurfaceSweep { sketch, .. } => Some(sketch),
|
||||
_ => None,
|
||||
};
|
||||
for sketch in profile.into_iter().chain(rails.iter().map(|r| &r.sketch)) {
|
||||
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
|
||||
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||||
}
|
||||
return hash.finish();
|
||||
}
|
||||
@@ -1190,6 +1256,23 @@ impl Model {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the results are `doc`'s evaluation: one per feature, each on the inputs `doc`
|
||||
/// gives it, none stale, so [`Model::evaluate`] would keep every one of them. A document
|
||||
/// changed without an evaluation since is not.
|
||||
pub fn is_current(&self, doc: &Document) -> bool {
|
||||
if self.results.len() != doc.features.len() || self.is_stale() {
|
||||
return false;
|
||||
}
|
||||
let vals = evaluation_values(doc).0;
|
||||
let steps = StepDigests::of(doc, &self.step_digests);
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
let mut prev = 0;
|
||||
doc.features.iter().zip(&self.results).enumerate().all(|(i, (f, r))| {
|
||||
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &steps);
|
||||
r.id == f.id && r.fingerprint == prev
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-evaluate the document, reusing results up to the first changed feature.
|
||||
pub fn evaluate(&mut self, doc: &Document) {
|
||||
let (vals, perr) = evaluation_values(doc);
|
||||
@@ -1333,9 +1416,20 @@ impl Model {
|
||||
}),
|
||||
});
|
||||
if r.is_ok() {
|
||||
// Profiles picked by curves that no longer bound a profile exactly were found
|
||||
// by their best match: say which.
|
||||
let thin = matches!(&f.kind, FeatureKind::Extrude { thin: Some(_), .. });
|
||||
for (sketch, sel) in f.kind.profile_sels() {
|
||||
if let (false, Some(ss)) = (thin, state.sketch(sketch)) {
|
||||
for note in crate::profile_fallback_notes(ss, sel) {
|
||||
append_warning(&mut warning, note);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(note) = boolean_diagnostics.warning() {
|
||||
append_warning(&mut warning, note);
|
||||
}
|
||||
track_altered_sheets(&state, &mut next, f, &mut warning);
|
||||
} else {
|
||||
boolean_diagnostics = Default::default();
|
||||
}
|
||||
@@ -1422,15 +1516,12 @@ pub(crate) fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f6
|
||||
Document::eval_in(vals, e, k)
|
||||
}
|
||||
|
||||
/// A loft builds each section from its outline alone, so a section with a hole (a ring) is
|
||||
/// refused rather than lofted as if it were filled.
|
||||
/// Collapsing the outline and its holes onto one apex cannot make a manifold solid.
|
||||
fn refuse_loft_holes(r: &Region2) -> Result<()> {
|
||||
if r.holes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(DocError::Invalid(
|
||||
"not supported yet: a loft section with a hole; loft the outline and the hole as two bodies and cut one from the other".into(),
|
||||
))
|
||||
Err(DocError::Invalid("not supported yet: lofting a section with holes to a point".into()))
|
||||
}
|
||||
|
||||
pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region2>> {
|
||||
@@ -1454,8 +1545,11 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region
|
||||
}
|
||||
v
|
||||
}
|
||||
// A curve loop that no longer bounds a profile exactly falls back to the best match; the
|
||||
// feature's warning says so (`profile_fallback_notes`).
|
||||
ProfileSel::Curves { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } => {
|
||||
crate::profile_selection_indices(ss, sel)?
|
||||
crate::profile_selection_indices_lenient(ss, sel)?
|
||||
.0
|
||||
.into_iter()
|
||||
.map(|index| {
|
||||
ps.get(index)
|
||||
@@ -1542,6 +1636,52 @@ fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> St
|
||||
unique_body_name(state, base)
|
||||
}
|
||||
|
||||
/// Keeps each sheet metal body's flat pattern true to the body (see `SheetBody::altered_by`).
|
||||
/// A feature that changes a sheet body's material but not its sheet model (a fillet, a join, a
|
||||
/// mirror combined into it) marks the sheet with its name, and says so. A feature that rebuilds
|
||||
/// the body from the model (a sheet metal feature, or machining the model keeps as cut-outs,
|
||||
/// which changes the model) clears the mark, and says that the change it drops is not kept.
|
||||
/// Features that move whole bodies or only mark their faces (a cosmetic thread, not a modelled
|
||||
/// one, which cuts material) leave the flat pattern as it was.
|
||||
fn track_altered_sheets(before: &ModelState, after: &mut ModelState, f: &Feature, warning: &mut Option<String>) {
|
||||
if matches!(
|
||||
f.kind,
|
||||
FeatureKind::Move { .. }
|
||||
| FeatureKind::Align { .. }
|
||||
| FeatureKind::Rest { .. }
|
||||
| FeatureKind::BodyTransfer { .. }
|
||||
| FeatureKind::SplitFace { .. }
|
||||
| FeatureKind::Thread { modeled: false, .. }
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let ModelState { sheets, bodies, .. } = after;
|
||||
for sh in sheets.iter_mut() {
|
||||
let Some(old) = before.sheets.iter().find(|s| s.body == sh.body) else { continue };
|
||||
let (Some(was), Some(now)) = (before.body(&sh.body), bodies.iter().find(|b| b.name == sh.body)) else { continue };
|
||||
if was.body.same_shape(&now.body) {
|
||||
continue;
|
||||
}
|
||||
if f.kind.is_sheet() || sh != old {
|
||||
if let Some(lost) = sh.altered_by.take() {
|
||||
append_warning(
|
||||
warning,
|
||||
format!("`{}` is rebuilt from its sheet metal features: what {lost} changed is not kept (not supported yet)", sh.body),
|
||||
);
|
||||
}
|
||||
} else if sh.altered_by.is_none() {
|
||||
sh.altered_by = Some(f.name.clone());
|
||||
append_warning(
|
||||
warning,
|
||||
format!(
|
||||
"`{}` is sheet metal: its flat pattern, DXF and bend table can't include this change yet and are refused; a later sheet metal feature rebuilds the body without it",
|
||||
sh.body
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add tool bodies to the model according to the operation.
|
||||
fn sheet_boolean_unchanged(body: &Body, tools: &[Body], op: Operation) -> Result<()> {
|
||||
if sheet_boolean_changes(body, tools, op)? {
|
||||
@@ -1864,12 +2004,7 @@ pub fn through_all_distance(st: &ModelState, plane: &Plane, targets: &[String])
|
||||
/// Sample points inside a region (sketch coordinates).
|
||||
fn region_samples(r: &Region2) -> Vec<Vec2> {
|
||||
let mut out = vec![r.interior_point()];
|
||||
let pts = r.outer.polyline(1e-2);
|
||||
let (mut lo, mut hi) = (Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY));
|
||||
for p in &pts {
|
||||
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
|
||||
}
|
||||
let solidcraft_geom::Aabb2 { min: lo, max: hi } = r.outer.polyline(1e-2).bounds();
|
||||
for i in 0..5 {
|
||||
for j in 0..5 {
|
||||
let p = Vec2::new(lo.x + (hi.x - lo.x) * (0.1 + 0.2 * i as f64), lo.y + (hi.y - lo.y) * (0.1 + 0.2 * j as f64));
|
||||
@@ -2390,7 +2525,7 @@ fn faces_through(st: &ModelState, p: Vec3, reach: f64) -> Vec<(f64, Vec3, Option
|
||||
}
|
||||
for (f, (d, facet)) in near {
|
||||
let (exact, off) = match kernel::face_surface(&b.body, f as usize) {
|
||||
Some(kernel::FaceSurface::Plane { normal, point }) => (Some(normal), Some((p - point).dot(normal).abs())),
|
||||
Some(kernel::FaceSurface::Plane { normal, point }) => (Some(*normal), Some((p - point).dot(normal).abs())),
|
||||
Some(kernel::FaceSurface::Cylinder { origin, axis, .. }) => {
|
||||
let v = p - origin;
|
||||
((v - axis * v.dot(axis)).normalized(), None)
|
||||
@@ -2884,6 +3019,38 @@ fn hole_tools(
|
||||
Ok(hole_tools_and_prisms(vals, st, position, dir, diameter, depth, hole)?.into_iter().map(|(tool, _)| tool).collect())
|
||||
}
|
||||
|
||||
/// The scale a hole's tools are sized by: the largest body the hole's axis meets (#2904), so a
|
||||
/// large body elsewhere in the model changes neither the tool's reach above the face (and so
|
||||
/// the countersink's outline) nor its depth. A hole whose axis meets no body is sized by the
|
||||
/// largest body of the model, as before.
|
||||
fn hole_axis_size(st: &ModelState, position: &Vec3, dir: Vec3) -> f64 {
|
||||
let along = |b: &ModelBody| {
|
||||
// The axis (an unbounded line) against the body's box, grown by the display tolerance.
|
||||
let bb = b.mesh().bounds();
|
||||
if bb.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let pad = 2.0 * b.display_tol();
|
||||
let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
for (o, d, min, max) in
|
||||
[(position.x, dir.x, bb.min.x, bb.max.x), (position.y, dir.y, bb.min.y, bb.max.y), (position.z, dir.z, bb.min.z, bb.max.z)]
|
||||
{
|
||||
if d.abs() < 1e-12 {
|
||||
if o < min - pad || o > max + pad {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
let (a, c) = ((min - pad - o) / d, (max + pad - o) / d);
|
||||
lo = lo.max(a.min(c));
|
||||
hi = hi.min(a.max(c));
|
||||
}
|
||||
}
|
||||
lo <= hi
|
||||
};
|
||||
let met = st.bodies.iter().filter(|b| along(b)).map(|b| b.body.size()).fold(0.0, f64::max);
|
||||
if met > 0.0 { met } else { st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max) }
|
||||
}
|
||||
|
||||
/// [`hole_tools`], each with its exact geometry when it is a straight cylinder along the axis
|
||||
/// (a plain hole, a counterbore, a drill point left in the air past a through hole), so sheet
|
||||
/// metal can keep it as a flat cut-out or pocket. Each tool's faces are named by the part of the
|
||||
@@ -2902,7 +3069,7 @@ fn hole_tools_and_prisms(
|
||||
if !(r > 1e-6) {
|
||||
return Err(DocError::Invalid("hole diameter must be positive".into()));
|
||||
}
|
||||
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
|
||||
let size = hole_axis_size(st, position, dir);
|
||||
let top = (size * 0.01).max(0.1);
|
||||
let bottom = match depth {
|
||||
Some(d) => -val(vals, d, Kind::Length)?,
|
||||
@@ -3660,8 +3827,11 @@ fn feature_tools_with_constructor(
|
||||
let m = crate::rigid(Vec3::ZERO, origin_w, axis_w, -start);
|
||||
regions.iter().zip(bodies).map(|(r, body)| Ok(tool(r, kernel::transform_matrix(&body, m)?, Some(&body)))).collect()
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.point.is_some()) => {
|
||||
FeatureKind::Loft { sections, rails, .. } if sections.iter().any(|s| s.point.is_some()) => {
|
||||
// A profile lofted to a sketch point (an apex).
|
||||
if !rails.is_empty() {
|
||||
return Err(DocError::Invalid("not supported yet: guide rails on a loft to a point".into()));
|
||||
}
|
||||
let [a, b] = §ions[..] else { return Err(DocError::Invalid("not supported yet: a point section in a loft of more than two".into())) };
|
||||
let (prof, pt) = if a.point.is_some() { (b, a) } else { (a, b) };
|
||||
let ss = st.sketch(prof.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {}", prof.sketch)))?;
|
||||
@@ -3714,7 +3884,7 @@ fn feature_tools_with_constructor(
|
||||
let pitch = if *clockwise { -p } else { p };
|
||||
Ok(unnamed(vec![kernel::sweep_helix(&plane, ®ion, *base, axis, pitch, n)?]))
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => {
|
||||
FeatureKind::Loft { sections, rails, rail_fit, .. } => {
|
||||
let mut secs = Vec::new();
|
||||
let mut sketches = Vec::new();
|
||||
for sec in sections {
|
||||
@@ -3723,14 +3893,27 @@ fn feature_tools_with_constructor(
|
||||
.ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?;
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &sec.profiles)?);
|
||||
let [r] = ®ions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
|
||||
refuse_loft_holes(r)?;
|
||||
secs.push((ss.plane, r.outer.clone()));
|
||||
secs.push((ss.plane, r.clone()));
|
||||
sketches.push(ss);
|
||||
}
|
||||
// Its caps are the first and last sections, its sides named by the curves they run
|
||||
// between.
|
||||
let body = kernel::loft(&secs)?;
|
||||
let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() };
|
||||
let body = if rails.is_empty() {
|
||||
kernel::loft_regions(&secs)?
|
||||
} else {
|
||||
// Rails shape the sections' outlines; sections with holes wait for rails that
|
||||
// carry the holes too.
|
||||
if secs.iter().any(|(_, r)| !r.holes.is_empty()) {
|
||||
return Err(DocError::Invalid("not supported yet: guide rails on a loft whose sections have holes".into()));
|
||||
}
|
||||
let outlines: Vec<_> = secs.iter().map(|(p, r)| (*p, r.outer.clone())).collect();
|
||||
// The rails run from the end at the first section.
|
||||
let start = outlines.first().and_then(|(p, lp)| lp.segs.first().map(|seg| p.to_world(seg.start()))).unwrap_or_default();
|
||||
kernel::loft_with_rails(&outlines, &rail_polylines(doc, st, rails, f.component, start)?, kernel_rail_fit(*rail_fit))?
|
||||
};
|
||||
let roles = crate::lineage::SectionRoles {
|
||||
sections: sketches.into_iter().zip(secs.iter().map(|(_, r)| std::iter::once(&r.outer).chain(&r.holes).collect())).collect(),
|
||||
};
|
||||
Ok(vec![roles.tool(body)])
|
||||
}
|
||||
FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, rails, rail_fit, .. } => {
|
||||
@@ -3840,20 +4023,24 @@ pub(crate) fn sweep_control(
|
||||
Some(k) => val(vals, k, Kind::Unitless)?,
|
||||
None => 1.0,
|
||||
};
|
||||
let rail_fit = match rail_fit {
|
||||
crate::RailFit::Scale => kernel::RailFit::Scale,
|
||||
crate::RailFit::Stretch => kernel::RailFit::Stretch,
|
||||
};
|
||||
let ctl = kernel::SweepControl { orientation, twist, scale, rails, rail_fit };
|
||||
let ctl = kernel::SweepControl { orientation, twist, scale, rails, rail_fit: kernel_rail_fit(rail_fit) };
|
||||
ctl.check()?;
|
||||
Ok(ctl)
|
||||
}
|
||||
|
||||
/// A sweep's guide rails as polylines in the feature's frame, each a chain of its sketch's curves
|
||||
/// (planar or 3D) from the end nearest `start`, the start of the path.
|
||||
/// How guide rails reshape a sweep's profile or a loft's sections, for the kernel.
|
||||
pub(crate) fn kernel_rail_fit(fit: crate::RailFit) -> kernel::RailFit {
|
||||
match fit {
|
||||
crate::RailFit::Scale => kernel::RailFit::Scale,
|
||||
crate::RailFit::Stretch => kernel::RailFit::Stretch,
|
||||
}
|
||||
}
|
||||
|
||||
/// A sweep's or a loft's guide rails as polylines in the feature's frame, each a chain of its sketch's curves
|
||||
/// (planar or 3D) from the end nearest `start` (the start of the path, the loft's first section).
|
||||
pub(crate) fn rail_polylines(doc: &Document, st: &ModelState, rails: &[crate::SweepRail], consumer: u64, start: Vec3) -> Result<Vec<Vec<Vec3>>> {
|
||||
if rails.len() > 2 {
|
||||
return Err(DocError::Invalid("a sweep takes one or two guide rails".into()));
|
||||
return Err(DocError::Invalid("a sweep or a loft takes one or two guide rails".into()));
|
||||
}
|
||||
rails
|
||||
.iter()
|
||||
@@ -4064,9 +4251,17 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<k
|
||||
|
||||
/// Where an extrude of distance `d` starts and ends along the sketch normal, before any start
|
||||
/// offset: one side, the other, both (symmetric), or a second distance on the other side.
|
||||
///
|
||||
/// A second distance (`distance2`) goes the other way from the first side, wherever that went:
|
||||
/// against the normal for `direction: negative` (#2700), and back along it when a negative
|
||||
/// distance or a To Object below the sketch sends the first side down.
|
||||
pub(crate) fn extent_sides(vals: &BTreeMap<String, Value>, extent: &crate::Extent, d: f64, direction: crate::Direction) -> Result<(f64, f64)> {
|
||||
Ok(match (&extent.distance2, direction) {
|
||||
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
|
||||
(Some(d2), _) => {
|
||||
let first = if direction == crate::Direction::Negative { -d } else { d };
|
||||
let d2 = val(vals, d2, Kind::Length)?;
|
||||
(if first < 0.0 { d2 } else { -d2 }, first)
|
||||
}
|
||||
(None, crate::Direction::Positive) => (0.0, d),
|
||||
(None, crate::Direction::Negative) => (-d, 0.0),
|
||||
(None, crate::Direction::Symmetric) => {
|
||||
@@ -4356,6 +4551,9 @@ fn replay(
|
||||
continue;
|
||||
}
|
||||
let (op, targets) = feature_op(src);
|
||||
// A primitive's or a hole's targets are its participants (#1108): the copies change
|
||||
// those bodies only, like the feature itself (#2755).
|
||||
let targets = if targets.is_empty() { src.participants.clone() } else { targets };
|
||||
let mut copies = Vec::new();
|
||||
if let FeatureKind::Hole { diameter, depth, hole, to, .. } = &src.kind {
|
||||
// A hole is drilled again at each copy of its spot (found on the surface there), so
|
||||
@@ -4398,8 +4596,11 @@ fn replay(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[path = "model_cache.rs"]
|
||||
mod model_cache;
|
||||
#[path = "eval_more.rs"]
|
||||
mod more;
|
||||
pub use model_cache::SavedModel;
|
||||
#[path = "eval_surface.rs"]
|
||||
mod surface;
|
||||
|
||||
@@ -4763,7 +4964,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
None => state_in_frame(doc, st, f.component),
|
||||
};
|
||||
let (plane, face_warning) = match (anchor, plane) {
|
||||
(Some(_), plane) => (doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?, None),
|
||||
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
|
||||
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
|
||||
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
|
||||
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
|
||||
@@ -4922,7 +5123,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let pl = match crate::face_anchor::plane_face_anchor(plane) {
|
||||
Some(anchor) => {
|
||||
let fst = crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?;
|
||||
doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?
|
||||
let (pl, note) = doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?;
|
||||
if let Some(note) = note {
|
||||
append_warning(warning, note);
|
||||
}
|
||||
pl
|
||||
}
|
||||
// A plane of numbers alone reads no body: placing them all is the design's size.
|
||||
None if plane_is_fixed(plane) => doc.resolve_plane_in(vals, plane, 0, Some(st))?,
|
||||
@@ -5008,7 +5213,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
if curves.is_empty() {
|
||||
return Err(DocError::Invalid(format!("sketch `{}` has no curves to split with", ss.name)));
|
||||
}
|
||||
kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Curves { plane: ss.plane, curves })?
|
||||
kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Curves { plane: ss.plane, curves, along: None })?
|
||||
}
|
||||
(None, None) => {
|
||||
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
|
||||
@@ -5707,11 +5912,19 @@ impl PlacedBodies {
|
||||
Some((_, placed)) => {
|
||||
let mut fresh = ModelBody::new(name, placed.body.clone(), b.feature);
|
||||
fresh.mesh = placed.mesh.clone();
|
||||
fresh.mesh_placement = placed.mesh_placement.clone();
|
||||
fresh.plan = placed.plan.clone();
|
||||
fresh.plan_mesh = placed.plan_mesh.clone();
|
||||
fresh
|
||||
}
|
||||
None => ModelBody::new(name, kernel::transform_matrix(&b.body, *m).unwrap_or_else(|_| b.body.clone()), b.feature),
|
||||
None => match kernel::place_matrix(&b.body, *m) {
|
||||
Ok(body) => {
|
||||
let mut fresh = ModelBody::new(name, body, b.feature);
|
||||
fresh.mesh_placement = placed_mesh::PlacedMesh::new(b, m).map(Arc::new);
|
||||
fresh
|
||||
}
|
||||
Err(_) => ModelBody::new(name, b.body.clone(), b.feature),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5746,26 +5959,46 @@ pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut
|
||||
path
|
||||
};
|
||||
let mut out = ModelState { body_counter: st.body_counter, ..Default::default() };
|
||||
for (comp, path, m) in doc.placements() {
|
||||
let placements = doc.placements();
|
||||
// A body moved in from another component keeps its place (its offset).
|
||||
let body_matrix = |b: &ModelBody, m: &crate::Mat| doc.model_body_offset(b).map(|o| crate::mat_mul(m, o)).unwrap_or(*m);
|
||||
// Every body placed elsewhere than its definition, placed on all cores: a large assembly
|
||||
// places hundreds of parts, each a B-rep transform.
|
||||
let mut jobs = Vec::new();
|
||||
for (comp, path, m) in &placements {
|
||||
for b in st.bodies.iter().filter(|b| doc.model_body_component(b) == *comp) {
|
||||
let bm = body_matrix(b, m);
|
||||
if !crate::is_identity(&bm) {
|
||||
jobs.push((b, bm, doc.world_body_name(st, &b.name, *comp, path)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut moved = kernel::map_on_threads(&jobs, 16, |(b, bm, name)| placed_before.placed(b, bm, name.clone())).into_iter().zip(&jobs);
|
||||
for (comp, path, m) in placements {
|
||||
let primary = path == first_path(comp);
|
||||
let ident = crate::is_identity(&m);
|
||||
for b in st.bodies.iter().filter(|b| doc.model_body_component(b) == comp) {
|
||||
let name = doc.world_body_name(st, &b.name, comp, &path);
|
||||
// A body moved in from another component keeps its place (its offset).
|
||||
let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
|
||||
let bm = body_matrix(b, &m);
|
||||
// Identical geometry also shares its display mesh cell with the definition.
|
||||
// A changed placement keeps a distinct mesh, at that placement's coordinates.
|
||||
let mut placed = if crate::is_identity(&bm) {
|
||||
let mut placed = b.clone();
|
||||
placed.name = name;
|
||||
placed.name = doc.world_body_name(st, &b.name, comp, &path);
|
||||
placed
|
||||
} else {
|
||||
let placed = placed_before.placed(b, &bm, name);
|
||||
// The same order as the jobs: this body's, made above (again here if it failed).
|
||||
let placed = match moved.next() {
|
||||
Some((Some(placed), _)) => placed,
|
||||
Some((None, (_, _, name))) => placed_before.placed(b, &bm, name.clone()),
|
||||
None => placed_before.placed(b, &bm, doc.world_body_name(st, &b.name, comp, &path)),
|
||||
};
|
||||
placed_now.by_source.insert(placed_key(b, &bm), (b.clone(), placed.clone()));
|
||||
placed
|
||||
};
|
||||
// World states keep their previous display-only evaluation-key semantics.
|
||||
placed.key = 0;
|
||||
// With the model body's own frame, so its place is told apart from the placement's.
|
||||
placed.source_key = b.geometry_key().unwrap_or(0);
|
||||
placed.source_name = Some(b.definition_name().to_string());
|
||||
placed.source_transform = crate::mat_mul(&bm, &b.source_transform);
|
||||
placed.component = comp;
|
||||
@@ -5805,7 +6038,7 @@ pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut
|
||||
let mut t2 = t.clone();
|
||||
let frame = st.body(&t.body).and_then(|body| doc.model_body_offset(body)).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m);
|
||||
t2.axis_point = crate::apply_point(&frame, t.axis_point);
|
||||
t2.axis = crate::apply_vector(&frame, t.axis);
|
||||
t2.axis = crate::apply_vector(&frame, *t.axis).unit().unwrap_or(t.axis);
|
||||
out.threads.push(t2);
|
||||
}
|
||||
}
|
||||
@@ -5874,6 +6107,8 @@ mod world_placement_cache_tests {
|
||||
assert!(Arc::ptr_eq(&source.mesh, &first.mesh), "unchanged geometry must retain the shared display mesh cell");
|
||||
assert_eq!(first.key, 0);
|
||||
assert_eq!(placed.key, 0);
|
||||
// Each placement has its own geometry identity, for work measured on it.
|
||||
assert!(first.geometry_key().is_some() && first.geometry_key() != placed.geometry_key());
|
||||
assert!(!Arc::ptr_eq(&source.mesh, &placed.mesh), "a transformed mesh cannot be reused without mapping coordinates");
|
||||
assert_eq!(first.source_name.as_deref(), Some("Block"));
|
||||
assert_eq!(placed.source_name.as_deref(), Some("Block"));
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::*;
|
||||
use crate::EmbossFace;
|
||||
use crate::apply_point;
|
||||
use crate::sheet;
|
||||
use solidcraft_geom::{Aabb3, Loop2, Seg2};
|
||||
use solidcraft_geom::{Aabb3, Loop2, Ring, Seg2};
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
match &f.kind {
|
||||
@@ -97,20 +97,22 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
let tn = tp.normal();
|
||||
let i = body_at(st, body, &crate::plain(faces))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let mut b = mb.body.clone();
|
||||
// Each face moved is a step of the body's lineage, as Offset Face's is: without it no face of
|
||||
// the result could take a sketch's attachment.
|
||||
let mut b = mb.traced();
|
||||
for p in faces {
|
||||
let n =
|
||||
planar_normal_at(&b, p.0).ok_or_else(|| DocError::Invalid("not supported yet: replacing a face that is not planar".into()))?;
|
||||
let n = planar_normal_at(&b.body, p.0)
|
||||
.ok_or_else(|| DocError::Invalid("not supported yet: replacing a face that is not planar".into()))?;
|
||||
if n.dot(tn).abs() < 1.0 - 1e-9 {
|
||||
return Err(DocError::Invalid("not supported yet: a target that is not parallel to the face".into()));
|
||||
}
|
||||
let d = (tp.origin - p.0).dot(n);
|
||||
if d.abs() > 1e-9 {
|
||||
b = kernel::offset_faces(&b, &[p.0], d)?;
|
||||
b = Traced::step(kernel::offset_faces(&b.body, &[p.0], d)?, &[&b]);
|
||||
}
|
||||
}
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, b, mb.feature);
|
||||
*slot = ModelBody::traced_new(mb.name, b, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -739,8 +741,10 @@ pub(super) fn sheet_eval(
|
||||
let t = hi - lo;
|
||||
// The face outline: boundary edges of the triangles on that face's plane, where it runs
|
||||
// along a round wall an arc of the wall's circle (not chords: the plate check below
|
||||
// compares its area with the exact volume).
|
||||
let plane = Plane::from_normal(face.0 + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
|
||||
// compares its area with the exact volume). The sheet's frame lies on the picked face
|
||||
// (its outer level, `hi`) with z into the material, as with bend edges, so the sheet
|
||||
// takes the plate's place: its thickness runs from `hi` back to `lo`.
|
||||
let plane = Plane::from_normal(face.0 + n * hi, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
|
||||
// Recesses in one face (an engraved label) become pockets; the outline is the other face's.
|
||||
let recesses = plate_recesses(&mb.body, &mesh, &plane, face.0, n, (lo, hi), tol)?;
|
||||
let mut walls = std::collections::HashMap::new();
|
||||
@@ -773,11 +777,6 @@ pub(super) fn sheet_eval(
|
||||
}
|
||||
}
|
||||
|
||||
fn poly_area(p: &[Vec2]) -> f64 {
|
||||
let m = p.len();
|
||||
(0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
/// Current unmodified extrusion caps, including placement, certify the tool's depth.
|
||||
/// Tessellation can drift in thickness at distant coordinates and cannot certify a slab.
|
||||
fn sheet_tool_span(body: &Body, source: &Plane) -> Option<(f64, f64, f64, Option<(Plane, Region2)>)> {
|
||||
@@ -1361,9 +1360,7 @@ fn mesh_box(mb: &ModelBody) -> Option<Aabb3> {
|
||||
|
||||
fn padded_box(mesh: &solidcraft_geom::Mesh, tol: f64) -> Option<Aabb3> {
|
||||
let b = mesh.bounds();
|
||||
let pad = 2.0 * tol + 1e-6;
|
||||
let pad = Vec3::new(pad, pad, pad);
|
||||
(!b.is_empty() && b.min.is_finite() && b.max.is_finite()).then(|| Aabb3 { min: b.min - pad, max: b.max + pad })
|
||||
(!b.is_empty() && b.min.is_finite() && b.max.is_finite()).then(|| b.inflated(2.0 * tol + 1e-6))
|
||||
}
|
||||
|
||||
/// Whether two boxes are known to be apart (an unknown box is never apart).
|
||||
@@ -1403,7 +1400,7 @@ fn parted_note(sh: &sheet::SheetBody, material: &[Region2], before: &sheet::Shee
|
||||
|
||||
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
|
||||
/// `level` from `at` along `n`: the edges used by one of them only, chained.
|
||||
fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Ring<Vec2>> {
|
||||
face_outline(mesh, plane, at, n, level, tol).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect()
|
||||
}
|
||||
|
||||
@@ -1441,7 +1438,7 @@ fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3,
|
||||
}
|
||||
|
||||
/// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh.
|
||||
fn boundary_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, on: &[[u32; 3]]) -> Vec<Vec<Vec2>> {
|
||||
fn boundary_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, on: &[[u32; 3]]) -> Vec<Ring<Vec2>> {
|
||||
chain_loops(mesh, plane, on, |_, _| None).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect()
|
||||
}
|
||||
|
||||
@@ -1638,7 +1635,7 @@ fn plate_recesses(body: &Body, mesh: &Mesh, plane: &Plane, at: Vec3, n: Vec3, (l
|
||||
for (f, d, ..) in &floors {
|
||||
let on: Vec<[u32; 3]> = fine.triangles.iter().zip(&fine.tri_face).filter(|(_, g)| **g as usize == *f).map(|(tri, _)| *tri).collect();
|
||||
let mut loops = boundary_loops(&fine, plane, &on);
|
||||
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
|
||||
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
|
||||
let mut it = loops.into_iter();
|
||||
let Some(outline) = it.next() else { continue };
|
||||
let region = Region2 { outer: Loop2::polygon(&outline).ccw(), holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() };
|
||||
@@ -1835,24 +1832,24 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
let tol = (mb.body.size() * 1e-3).max(1e-3);
|
||||
let mesh = mb.body.tessellate(tol)?;
|
||||
let mut loops = face_loops(&mesh, &plane, face.0, n, 0.0, tol);
|
||||
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
|
||||
let ccw = |l: &[Vec2]| if poly_area(l) < 0.0 { l.iter().rev().copied().collect::<Vec<_>>() } else { l.to_vec() };
|
||||
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
|
||||
let ccw = |l: &Ring<Vec2>| if l.signed_area() < 0.0 { l.iter().rev().copied().collect::<Ring<_>>() } else { l.clone() };
|
||||
// The inner edge is an opening (the cavity under a rim): not the foot of a boss or a
|
||||
// wall standing on the face, whose side the band would run against.
|
||||
let opening = |l: &[Vec2]| {
|
||||
let opening = |l: &Ring<Vec2>| {
|
||||
let eps = (tol * 4.0).max(1e-3);
|
||||
l.iter().zip(l.iter().cycle().skip(1)).filter_map(|(a, b)| Some(((*a + *b) * 0.5, (*b - *a).normalized()?))).take(3).any(|(m, t)| {
|
||||
l.edges().filter_map(|(a, b)| Some(((*a + *b) * 0.5, (*b - *a).normalized()?))).take(3).any(|(m, t)| {
|
||||
// Just over the face, inside the loop (to the left of a ccw loop's edges).
|
||||
let q = plane.to_world(m + Vec2::new(-t.y, t.x) * eps) + n * eps;
|
||||
!mesh.contains(q)
|
||||
})
|
||||
};
|
||||
let outer = loops.first().map(|l| ccw(l));
|
||||
let openings: Vec<Vec<Vec2>> = loops.iter().skip(1).map(|l| ccw(l)).filter(|l| opening(l)).collect();
|
||||
let outer = loops.first().map(ccw);
|
||||
let openings: Vec<Ring<Vec2>> = loops.iter().skip(1).map(ccw).filter(|l| opening(l)).collect();
|
||||
// A rim runs round one opening, the cavity; smaller ones (a screw hole) may sit in it.
|
||||
// Openings alike in size (a plate's holes) leave no edge that is the rim's.
|
||||
let alike = match openings.as_slice() {
|
||||
[a, b, ..] => poly_area(b).abs() > 0.5 * poly_area(a).abs(),
|
||||
[a, b, ..] => b.signed_area().abs() > 0.5 * a.signed_area().abs(),
|
||||
_ => false,
|
||||
};
|
||||
let (Some(outer), Some(inner), false) = (outer, openings.first().cloned(), alike) else {
|
||||
@@ -1877,16 +1874,24 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
// The band along the chosen edge, inside the rim face. A groove's cutter reaches past
|
||||
// the edge into the free side (the cavity, or outside), so no wall coincides.
|
||||
let free = if *groove { bw } else { 0.0 };
|
||||
let band = if *outside {
|
||||
Region2 {
|
||||
outer: Loop2::polygon(&offset_left(&outer, -free)).ccw(),
|
||||
holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()],
|
||||
}
|
||||
let (plane, band) = if let Ok(exact) = kernel::rim_band(&mb.body, face.0, bw, free, *outside) {
|
||||
exact
|
||||
} else if *outside {
|
||||
(
|
||||
plane,
|
||||
Region2 {
|
||||
outer: Loop2::polygon(&offset_left(&outer, -free)).ccw(),
|
||||
holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()],
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Region2 {
|
||||
outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(),
|
||||
holes: vec![Loop2::polygon(&offset_left(&inner, free)).ccw().reversed()],
|
||||
}
|
||||
(
|
||||
plane,
|
||||
Region2 {
|
||||
outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(),
|
||||
holes: vec![Loop2::polygon(&offset_left(&inner, free)).ccw().reversed()],
|
||||
},
|
||||
)
|
||||
};
|
||||
let delta = (h * 0.05).clamp(1e-3, 0.5);
|
||||
if *groove {
|
||||
|
||||
@@ -67,20 +67,46 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
apply_op(st, f, tools, *operation, &crate::plain(targets))
|
||||
}
|
||||
FeatureKind::SurfaceTrim { body, plane, tool, keep } => {
|
||||
FeatureKind::SurfaceTrim { body, plane, tool, keep, keep_more, curves, with, keep_with, stitch, extend } => {
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let trimmed = match tool {
|
||||
Some(t) => {
|
||||
let picks: Vec<Vec3> = std::iter::once(keep.0).chain(keep_more.iter().map(|p| p.0)).collect();
|
||||
let keeps = if keep_more.is_empty() { kernel::TrimKeep::Side(keep.0) } else { kernel::TrimKeep::Regions(&picks) };
|
||||
if let Some(other) = with {
|
||||
return mutual_trim(st, i, other, keeps, &crate::plain(keep_with), *stitch, *extend);
|
||||
}
|
||||
let trimmed = match (tool, curves) {
|
||||
(Some(t), _) => {
|
||||
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
|
||||
let face = crate::appearance::face_index_at(tb, t.point.0)
|
||||
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
|
||||
kernel::trim(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face }, keep.0)?
|
||||
kernel::trim_keep(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face }, keeps)?
|
||||
}
|
||||
None => kernel::trim(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?), keep.0)?,
|
||||
(None, Some(c)) => {
|
||||
let ss = st
|
||||
.sketch(c.sketch.0)
|
||||
.ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", c.sketch)))?;
|
||||
let ss = sketch_in_feature_frame(doc, ss, f.component)?;
|
||||
let ids: Vec<String> = if c.curves.is_empty() {
|
||||
ss.sketch.curves.iter().filter(|k| !k.construction).map(|k| k.id.clone()).collect()
|
||||
} else {
|
||||
c.curves.clone()
|
||||
};
|
||||
let chains = curve_chains(&ss, &ids)?;
|
||||
if chains.is_empty() {
|
||||
return Err(DocError::Invalid(format!("sketch `{}` has no curves to trim with", ss.name)));
|
||||
}
|
||||
kernel::trim_keep(
|
||||
&mb.body,
|
||||
&kernel::SplitTool::Curves { plane: ss.plane, curves: chains, along: c.direction.map(|d| d.0) },
|
||||
keeps,
|
||||
)?
|
||||
}
|
||||
(None, None) => kernel::trim_keep(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?), keeps)?,
|
||||
};
|
||||
let made = Traced::step(trimmed, &[&mb.traced()]);
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, trimmed, mb.feature);
|
||||
*slot = ModelBody::traced_new(mb.name, made, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -115,7 +141,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceBlend { from, to, continuity, bulge, align, stitch } => {
|
||||
FeatureKind::SurfaceBlend { from, to, continuity, bulge, align, seam, stitch } => {
|
||||
let a = st.body(&from.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", from.body)))?.body.clone();
|
||||
let b = st.body(&to.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", to.body)))?.body.clone();
|
||||
if from.edges.is_empty() || to.edges.is_empty() {
|
||||
@@ -129,6 +155,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
*continuity,
|
||||
*bulge,
|
||||
align.map(|[p, q]| [p.0, q.0]),
|
||||
seam.map(|[p, q]| [p.0, q.0]),
|
||||
BLEND_STATIONS,
|
||||
*stitch,
|
||||
)?;
|
||||
@@ -362,11 +389,14 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
push_sheet(st, f, traced);
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceLoft { sections, continuity, crown } => {
|
||||
FeatureKind::SurfaceLoft { sections, continuity, crown, rails, rail_fit } => {
|
||||
// A loft with edge chains for sections, or one that has to leave its ends a particular
|
||||
// way, is built through `loft_blend` from the frames of every section; the plain one
|
||||
// (sketch chains only, both ends G0) is the loft of old.
|
||||
if *continuity != [kernel::Continuity::G0, kernel::Continuity::G0] || sections.iter().any(|s| s.body.is_some()) {
|
||||
if !rails.is_empty() {
|
||||
return Err(DocError::Invalid("not supported yet: guide rails on a loft with edge sections or end continuity".into()));
|
||||
}
|
||||
return loft_blend(st, f, sections, *continuity, *crown, doc);
|
||||
}
|
||||
let mut secs = Vec::with_capacity(sections.len());
|
||||
@@ -391,10 +421,17 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
secs.push((ss.plane, chain));
|
||||
sketches.push(ss);
|
||||
}
|
||||
let body = kernel::loft_sheet(&secs)?;
|
||||
let body = if rails.is_empty() {
|
||||
kernel::loft_sheet(&secs)?
|
||||
} else {
|
||||
// The rails run from the end at the first section.
|
||||
let start = secs.first().and_then(|(p, c)| c.first().map(|seg| p.to_world(seg.start()))).unwrap_or_default();
|
||||
let rails = crate::eval::rail_polylines(doc, st, rails, f.component, start)?;
|
||||
kernel::loft_sheet_with_rails(&secs, &rails, crate::eval::kernel_rail_fit(*rail_fit))?
|
||||
};
|
||||
// Each wall named by the section curves it runs between, as a loft's sides are.
|
||||
let loops: Vec<solidcraft_geom::Loop2> = secs.iter().map(|(_, c)| solidcraft_geom::Loop2 { segs: c.clone() }).collect();
|
||||
let traced = crate::lineage::SectionRoles { sections: sketches.iter().zip(&loops).collect() }.tool(body);
|
||||
let traced = crate::lineage::SectionRoles { sections: sketches.iter().zip(&loops).map(|(s, lp)| (s, vec![lp])).collect() }.tool(body);
|
||||
push_sheet(st, f, traced);
|
||||
Ok(())
|
||||
}
|
||||
@@ -669,6 +706,43 @@ fn boundary_frames(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Mutual Trim: the body at `i` and `other` trimmed by each other (each keeping its picks), the
|
||||
/// kept pieces sewn into the first when `stitch`; their faces named after the faces they are
|
||||
/// pieces of.
|
||||
fn mutual_trim(st: &mut ModelState, i: usize, other: &str, keeps: kernel::TrimKeep, keep_with: &[Vec3], stitch: bool, extend: bool) -> Result<()> {
|
||||
let j = st.bodies.iter().position(|b| b.name == other).ok_or_else(|| DocError::Unknown(format!("body `{other}`")))?;
|
||||
if i == j {
|
||||
return Err(DocError::Invalid("a surface can't trim itself: pick another surface".into()));
|
||||
}
|
||||
let (Some(a), Some(b)) = (st.bodies.get(i).cloned(), st.bodies.get(j).cloned()) else { return Err(DocError::Invalid("body".into())) };
|
||||
let keeps_with = match keep_with {
|
||||
[] => return Err(DocError::Invalid(format!("pick the side of `{other}` to keep"))),
|
||||
[p] => kernel::TrimKeep::Side(*p),
|
||||
ps => kernel::TrimKeep::Regions(ps),
|
||||
};
|
||||
let (ta, tb) = kernel::mutual_trim(&a.body, &b.body, keeps, keeps_with, extend)?;
|
||||
let (ta, tb) = (Traced::step(ta, &[&a.traced()]), Traced::step(tb, &[&b.traced()]));
|
||||
if stitch {
|
||||
let sewn = kernel::stitch(&[&ta.body, &tb.body], 0.0)?;
|
||||
if sewn.edge_count() >= ta.body.edge_count() + tb.body.edge_count() {
|
||||
return Err(DocError::Invalid("the trimmed surfaces don't meet along an edge: they can't be stitched".into()));
|
||||
}
|
||||
let sewn = Traced::step(sewn, &[&ta, &tb]);
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::traced_new(a.name, sewn, a.feature);
|
||||
}
|
||||
st.bodies.retain(|x| x.name != other);
|
||||
} else {
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::traced_new(a.name, ta, a.feature);
|
||||
}
|
||||
if let Some(slot) = st.bodies.get_mut(j) {
|
||||
*slot = ModelBody::traced_new(b.name, tb, b.feature);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a surface builder sweeps: the boundary loops of the picked profiles, then the picked
|
||||
/// curves chained end to end.
|
||||
fn sheet_chains(ss: &SolvedSketch, profiles: Option<&ProfileSel>, curves: &[String]) -> Result<Vec<Vec<Seg2>>> {
|
||||
|
||||
@@ -395,7 +395,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
out.push((Tok::Op('-'), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if c == ';' {
|
||||
// Fusion separates function arguments with `;` in some locales.
|
||||
// Some CAD tools separate function arguments with `;` in some locales.
|
||||
out.push((Tok::Op(','), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if "+-*/^(),%".contains(c) {
|
||||
@@ -897,10 +897,6 @@ pub fn eval_params(params: &[ParamDef], doc_len: f64) -> (BTreeMap<String, Value
|
||||
if cx.stack.len() > 512 {
|
||||
return Err(DocError::Expr("parameters nested too deeply".into()));
|
||||
}
|
||||
let Some((kind, _)) = unit_info(&p.unit).or(Some((Kind::Length, 1.0))) else {
|
||||
return Err(DocError::Expr(format!("unknown unit `{}`", p.unit)));
|
||||
};
|
||||
let defaults = Defaults::for_unit(&p.unit, cx.doc_len);
|
||||
cx.stack.push(name.to_string());
|
||||
let mut vals: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut err = None;
|
||||
@@ -918,17 +914,7 @@ pub fn eval_params(params: &[ParamDef], doc_len: f64) -> (BTreeMap<String, Value
|
||||
cx.stack.pop();
|
||||
let r = match err {
|
||||
Some(e) => Err(e),
|
||||
None => {
|
||||
eval_with_defaults(&p.expr, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))), defaults)
|
||||
.and_then(|v| {
|
||||
let x = v.to_kind_in(kind, defaults)?;
|
||||
Ok(match kind {
|
||||
Kind::Length => Value::length(x),
|
||||
Kind::Angle => Value::angle(x),
|
||||
Kind::Unitless => Value::num(x),
|
||||
})
|
||||
})
|
||||
}
|
||||
None => eval_def(p, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))), cx.doc_len),
|
||||
};
|
||||
match &r {
|
||||
Ok(v) => {
|
||||
@@ -949,6 +935,19 @@ pub fn eval_params(params: &[ParamDef], doc_len: f64) -> (BTreeMap<String, Value
|
||||
(cx.done, cx.errors)
|
||||
}
|
||||
|
||||
/// One parameter's value: its expression in its unit, with the values of the parameters it
|
||||
/// refers to from `lookup`.
|
||||
pub fn eval_def(p: &ParamDef, lookup: &dyn Fn(&str) -> Result<Value, DocError>, doc_len: f64) -> Result<Value, DocError> {
|
||||
let (kind, _) = unit_info(&p.unit).unwrap_or((Kind::Length, 1.0));
|
||||
let defaults = Defaults::for_unit(&p.unit, doc_len);
|
||||
let x = eval_with_defaults(&p.expr, lookup, defaults)?.to_kind_in(kind, defaults)?;
|
||||
Ok(match kind {
|
||||
Kind::Length => Value::length(x),
|
||||
Kind::Angle => Value::angle(x),
|
||||
Kind::Unitless => Value::num(x),
|
||||
})
|
||||
}
|
||||
|
||||
/// Dependency edges between parameters: (parameter, parameter it references).
|
||||
pub fn param_edges(params: &[ParamDef]) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Exact ordinary face attachments. Missing identities never invoke a proximity fallback.
|
||||
//! Exact ordinary face attachments. Missing identities never invoke a proximity fallback; the one
|
||||
//! fallback is an extrude cap whose sketch loop changed (`Document::anchored_face_indices`).
|
||||
use crate::config::ConfigFaceTarget;
|
||||
use crate::{DocError, Document, EmbossFace, FeatureKind, Model, ModelBody, ModelState, PlaneRef, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -104,22 +105,38 @@ impl Document {
|
||||
state: &ModelState,
|
||||
framed: &ModelState,
|
||||
) -> Result<Plane> {
|
||||
fn replace(doc: &Document, plane: &mut PlaneRef, author: u64, state: &ModelState, depth: usize) -> Result<()> {
|
||||
self.resolve_attached_plane_noted(vals, plane, author, state, framed).map(|(plane, _)| plane)
|
||||
}
|
||||
|
||||
/// [`Document::resolve_attached_plane`] with the note of an attachment found by fallback
|
||||
/// ([`Document::anchored_face_indices`]).
|
||||
pub fn resolve_attached_plane_noted(
|
||||
&self,
|
||||
vals: &std::collections::BTreeMap<String, crate::expr::Value>,
|
||||
plane: &PlaneRef,
|
||||
author: u64,
|
||||
state: &ModelState,
|
||||
framed: &ModelState,
|
||||
) -> Result<(Plane, Option<String>)> {
|
||||
fn replace(doc: &Document, plane: &mut PlaneRef, author: u64, state: &ModelState, depth: usize) -> Result<Option<String>> {
|
||||
if depth > 64 {
|
||||
return Err(DocError::Invalid("attached plane nesting exceeds budget".into()));
|
||||
}
|
||||
match plane {
|
||||
PlaneRef::Face { anchor: Some(anchor), .. } => *plane = PlaneRef::Custom { plane: doc.resolve_face_anchor(anchor, author, state)? },
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => {
|
||||
replace(doc, base, author, state, depth + 1)?
|
||||
PlaneRef::Face { anchor: Some(anchor), .. } => {
|
||||
let (resolved, note) = doc.resolve_face_anchor_noted(anchor, author, state)?;
|
||||
*plane = PlaneRef::Custom { plane: resolved };
|
||||
Ok(note)
|
||||
}
|
||||
_ => {}
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => {
|
||||
replace(doc, base, author, state, depth + 1)
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut resolved = plane.clone();
|
||||
replace(self, &mut resolved, author, state, 0)?;
|
||||
self.resolve_plane_in(vals, &resolved, 0, Some(framed))
|
||||
let note = replace(self, &mut resolved, author, state, 0)?;
|
||||
Ok((self.resolve_plane_in(vals, &resolved, 0, Some(framed))?, note))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,11 +210,92 @@ impl Document {
|
||||
Ok(anchor)
|
||||
}
|
||||
|
||||
/// The faces an attachment stands for, and a note when they were found by fallback.
|
||||
///
|
||||
/// An extrude's cap is certified by the loop of sketch curves around it, so editing that
|
||||
/// sketch's outline (a curve that now cuts the profile, a side deleted and drawn again, a
|
||||
/// notch trimmed into it) leaves no face with the exact certificate. The attachment then
|
||||
/// takes the cap of the same (solid) extrude, at the same end, from the same sketch, on the
|
||||
/// same body, whose loop shares the most of the stored curves, at least half of them (a tie
|
||||
/// is not guessed). It never does while the exact cap is still on another body (profiles
|
||||
/// reordered: the bodies swapped names). The note names what changed. Every other identity
|
||||
/// stays exact.
|
||||
pub(crate) fn anchored_face_indices(
|
||||
&self,
|
||||
state: &ModelState,
|
||||
body: &ModelBody,
|
||||
target: &ConfigFaceTarget,
|
||||
) -> Result<(Vec<usize>, Option<String>)> {
|
||||
let error = match self.configuration_face_indices(body, target) {
|
||||
Ok(indices) => return Ok((indices, None)),
|
||||
Err(error) => error,
|
||||
};
|
||||
let Some(crate::config::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }) = &target.certificate else { return Err(error) };
|
||||
let exact = |source: &Option<solidcraft_kernel::FaceSource>| {
|
||||
matches!(source, Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch: from, high: end, outer: around, holes: inside, .. })
|
||||
if *producer == target.origin_feature && from == sketch && end == high && around == outer && inside == holes)
|
||||
};
|
||||
if state.bodies.iter().any(|other| other.body.face_sources().is_some_and(|sources| sources.iter().any(exact))) {
|
||||
return Err(error);
|
||||
}
|
||||
// A thin wall's cap is signed by the curves the wall follows: a wall along other curves
|
||||
// is another wall, whose cap never stands for this one.
|
||||
let solid = self.feature(target.origin_feature).is_some_and(|feature| matches!(feature.kind, FeatureKind::Extrude { thin: None, .. }));
|
||||
if !solid || self.certify_config_face(target).is_err() || body.body.is_mesh() || body.definition_name() != target.body {
|
||||
return Err(error);
|
||||
}
|
||||
// The producer's caps at that end, grouped by the loop each was made from.
|
||||
let mut caps: std::collections::BTreeMap<&Vec<String>, Vec<usize>> = std::collections::BTreeMap::new();
|
||||
for (index, source) in body.body.face_sources().into_iter().flatten().enumerate() {
|
||||
if let Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch: from, high: end, outer: around, .. }) = source
|
||||
&& *producer == target.origin_feature
|
||||
&& from == sketch
|
||||
&& end == high
|
||||
{
|
||||
caps.entry(around).or_default().push(index);
|
||||
}
|
||||
}
|
||||
let stored: std::collections::BTreeSet<&str> = outer.iter().map(String::as_str).collect();
|
||||
let shared = |around: &[String]| around.iter().filter(|id| stored.contains(id.as_str())).count();
|
||||
let best = caps.keys().map(|around| shared(around)).max().unwrap_or(0);
|
||||
if best == 0 || best.saturating_mul(2) < stored.len() {
|
||||
return Err(error);
|
||||
}
|
||||
let mut chosen = caps.iter().filter(|(around, _)| shared(around) == best);
|
||||
let (Some((around, indices)), None) = (chosen.next(), chosen.next()) else { return Err(error) };
|
||||
let indices = self.certified_pieces(body, indices.clone(), target.origin_feature).map_err(|_| error)?;
|
||||
let now: std::collections::BTreeSet<&str> = around.iter().map(String::as_str).collect();
|
||||
let list = |ids: Vec<&str>| ids.iter().map(|id| format!("`{id}`")).collect::<Vec<_>>().join(", ");
|
||||
let mut changes = Vec::new();
|
||||
let gone: Vec<&str> = stored.difference(&now).copied().collect();
|
||||
if !gone.is_empty() {
|
||||
changes.push(format!("{} no longer around it", list(gone)));
|
||||
}
|
||||
let new: Vec<&str> = now.difference(&stored).copied().collect();
|
||||
if !new.is_empty() {
|
||||
changes.push(format!("{} new", list(new)));
|
||||
}
|
||||
let feature = self.feature(target.origin_feature).map_or_else(|| format!("F{}", target.origin_feature), |f| f.name.clone());
|
||||
let note = format!(
|
||||
"the face this sketch sits on, {}, changed when its sketch was edited ({}); using the {} cap of {feature} as it is now",
|
||||
self.describe_face_target(target),
|
||||
changes.join(", "),
|
||||
if *high { "top" } else { "bottom" },
|
||||
);
|
||||
Ok((indices, Some(note)))
|
||||
}
|
||||
|
||||
pub fn resolve_face_anchor(&self, anchor: &FaceAnchor, author: u64, state: &ModelState) -> Result<Plane> {
|
||||
self.resolve_face_anchor_noted(anchor, author, state).map(|(plane, _)| plane)
|
||||
}
|
||||
|
||||
/// [`Document::resolve_face_anchor`] with the note of an attachment found by fallback
|
||||
/// ([`Document::anchored_face_indices`]).
|
||||
pub fn resolve_face_anchor_noted(&self, anchor: &FaceAnchor, author: u64, state: &ModelState) -> Result<(Plane, Option<String>)> {
|
||||
anchor.validate()?;
|
||||
let body =
|
||||
state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("attached body `{}` no longer exists", anchor.target.body)))?;
|
||||
let indices = self.configuration_face_indices(body, &anchor.target)?;
|
||||
let (indices, note) = self.anchored_face_indices(state, body, &anchor.target)?;
|
||||
let first = indices.first().copied().ok_or_else(|| DocError::Unknown("attached face no longer exists".into()))?;
|
||||
let certificate = anchor.target.certificate.as_ref();
|
||||
let plane = self
|
||||
@@ -214,7 +312,9 @@ impl Document {
|
||||
let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &plane)?;
|
||||
let origin = canonical.origin + canonical.x * anchor.coordinates[0] + canonical.y * anchor.coordinates[1];
|
||||
let x = canonical.x * anchor.x_axis[0] + canonical.y * anchor.x_axis[1];
|
||||
Plane::new(origin, x, canonical.normal().cross(x)).ok_or_else(|| DocError::Invalid("attached sketch basis is degenerate".into()))
|
||||
let plane =
|
||||
Plane::new(origin, x, canonical.normal().cross(x)).ok_or_else(|| DocError::Invalid("attached sketch basis is degenerate".into()))?;
|
||||
Ok((plane, note))
|
||||
}
|
||||
|
||||
pub fn sketch_face_attachment(&self, feature: u64, model: &Model) -> FaceAttachmentStatus {
|
||||
|
||||
@@ -159,10 +159,16 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
"orientation": "parallel", "twist": "surface_sweep.twist", "scale": "surface_sweep.scale"}),
|
||||
),
|
||||
("surface_loft", json!({"type": "surface_loft", "sections": []})),
|
||||
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
|
||||
(
|
||||
"surface_blend",
|
||||
json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o], "seam": [o, o]}),
|
||||
),
|
||||
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
|
||||
// The Class-A surface family (surface.blend/boundary/match/fair).
|
||||
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
|
||||
(
|
||||
"surface_blend",
|
||||
json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o], "seam": [o, o]}),
|
||||
),
|
||||
("surface_boundary", json!({"type": "surface_boundary", "sketch": 1, "curves": ["l1", "l2"]})),
|
||||
(
|
||||
"surface_network",
|
||||
@@ -188,6 +194,15 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
"direction": [0, 0, 1], "face": [1, 0, 0]}),
|
||||
),
|
||||
("surface_trim", json!({"type": "surface_trim", "body": "B", "plane": plane("trim"), "keep": o})),
|
||||
(
|
||||
"surface_trim_curves",
|
||||
json!({"type": "surface_trim", "body": "B", "plane": plane("trim"), "keep": o, "keep_more": [o],
|
||||
"curves": {"sketch": 1, "curves": ["l1"], "direction": [0, 0, 1]}}),
|
||||
),
|
||||
(
|
||||
"surface_trim_mutual",
|
||||
json!({"type": "surface_trim", "body": "B", "plane": plane("trim"), "keep": o, "with": "B", "keep_with": [o, o], "stitch": true, "extend": true}),
|
||||
),
|
||||
("move", json!({"type": "move", "bodies": ["B"], "translate": ["move.x", "move.y", "move.z"], "angle": "move.angle"})),
|
||||
("import", json!({"type": "import", "file": "a.step", "step": ""})),
|
||||
("mesh_convert", json!({"type": "mesh_convert", "body": "B", "tolerance": "mesh_convert.tolerance"})),
|
||||
@@ -211,10 +226,11 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
|
||||
"combine" => json!({"target": "B", "tools": ["B"]}),
|
||||
"pattern_rectangular" | "pattern_circular" | "pattern_path" | "mirror" => json!({"bodies": ["B"]}),
|
||||
"hole_simple" | "hole_drilled" | "hole_counterbore" | "hole_countersink" => json!({"points": {"sketch": 3, "ids": ["p"]}}),
|
||||
"loft" => json!({"sections": [{"sketch": 4, "profiles": {"type": "all"}}, {"sketch": 5, "profiles": {"type": "all"}}], "targets": ["B"]}),
|
||||
"loft" => json!({"sections": [{"sketch": 4, "profiles": {"type": "all"}}, {"sketch": 5, "profiles": {"type": "all"}}], "targets": ["B"],
|
||||
"rails": [{"sketch": 6, "curves": ["l1"]}]}),
|
||||
"split" | "surface_trim" => json!({"tool": tool}),
|
||||
"split_line" => json!({"sketch": 6, "line": "l1"}),
|
||||
"surface_loft" => json!({"sections": [{"sketch": 4}, {"sketch": 5}]}),
|
||||
"surface_loft" => json!({"sections": [{"sketch": 4}, {"sketch": 5}], "rails": [{"sketch": 6, "curves": ["l1"]}]}),
|
||||
"split_face" => json!({"body": "B", "tool": tool, "sketch": 6}),
|
||||
"emboss" => json!({"face": {"body": "B", "at": o}, "targets": ["B"]}),
|
||||
"sheet_fold" => json!({"sketch": 7, "body": "B"}),
|
||||
|
||||
@@ -375,6 +375,16 @@ surface_trim
|
||||
Angle = trim.angle (Angle)
|
||||
Offset = trim.offset (Length)
|
||||
expressions: trim.angle, trim.offset
|
||||
surface_trim_curves
|
||||
inputs:
|
||||
Angle = trim.angle (Angle)
|
||||
Offset = trim.offset (Length)
|
||||
expressions: trim.angle, trim.offset
|
||||
surface_trim_mutual
|
||||
inputs:
|
||||
Angle = trim.angle (Angle)
|
||||
Offset = trim.offset (Length)
|
||||
expressions: trim.angle, trim.offset
|
||||
move
|
||||
inputs:
|
||||
X distance = move.x (Length)
|
||||
|
||||
@@ -150,12 +150,12 @@ surface_sweep
|
||||
surface_sweep_options
|
||||
surface_loft
|
||||
surface_blend
|
||||
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
|
||||
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], seam[0][0], seam[0][1], seam[0][2], seam[1][0], seam[1][1], seam[1][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
|
||||
surface_offset
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
surface_blend
|
||||
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
|
||||
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], seam[0][0], seam[0][1], seam[0][2], seam[1][0], seam[1][1], seam[1][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
|
||||
surface_boundary
|
||||
surface_network
|
||||
moves: u[1].edges[0][0], u[1].edges[0][1], u[1].edges[0][2], v[1].point[0], v[1].point[1], v[1].point[2]
|
||||
@@ -183,6 +183,10 @@ surface_ruled
|
||||
face points: [1, 0, 0]
|
||||
surface_trim
|
||||
moves: keep[0], keep[1], keep[2], plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2]
|
||||
surface_trim_curves
|
||||
moves: curves.direction[0], curves.direction[2], keep[0], keep[1], keep[2], keep_more[0][0], keep_more[0][1], keep_more[0][2], plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
|
||||
surface_trim_mutual
|
||||
moves: keep[0], keep[1], keep[2], keep_with[0][0], keep_with[0][1], keep_with[0][2], keep_with[1][0], keep_with[1][1], keep_with[1][2], plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
|
||||
move
|
||||
moves: rotate_axis[1], rotate_axis[2]
|
||||
import
|
||||
|
||||
@@ -60,8 +60,8 @@ hole_countersink
|
||||
remap_refs: points.sketch
|
||||
thread
|
||||
loft
|
||||
sketches: [4, 5]
|
||||
remap_refs: sections[0].sketch, sections[1].sketch
|
||||
sketches: [4, 5, 6]
|
||||
remap_refs: rails[0].sketch, sections[0].sketch, sections[1].sketch
|
||||
remap_bodies: targets[0]
|
||||
sweep
|
||||
sketches: [1, 2]
|
||||
@@ -171,8 +171,8 @@ surface_sweep_options
|
||||
sketches: [1, 2]
|
||||
remap_refs: path_sketch, sketch
|
||||
surface_loft
|
||||
sketches: [4, 5]
|
||||
remap_refs: sections[0].sketch, sections[1].sketch
|
||||
sketches: [4, 5, 6]
|
||||
remap_refs: rails[0].sketch, sections[0].sketch, sections[1].sketch
|
||||
surface_blend
|
||||
remap_bodies: from.body
|
||||
surface_offset
|
||||
@@ -208,6 +208,13 @@ surface_ruled
|
||||
surface_trim
|
||||
remap_refs: plane.base.base.name
|
||||
remap_bodies: body, tool.body
|
||||
surface_trim_curves
|
||||
sketches: [1]
|
||||
remap_refs: curves.sketch, plane.base.base.name
|
||||
remap_bodies: body
|
||||
surface_trim_mutual
|
||||
remap_refs: plane.base.base.name
|
||||
remap_bodies: body, with
|
||||
move
|
||||
remap_bodies: bodies[0]
|
||||
import
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! - 5: joins, cuts and intersects change only their own component's bodies unless bodies are
|
||||
//! named as `participants`; older features in designs with components get `participants:
|
||||
//! ["*"]` (every body), which is what they did, so those designs keep their shape.
|
||||
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
|
||||
//! - 4: joint origins mate by default (B's z against A's); `flip` now means
|
||||
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
|
||||
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
|
||||
//! explicit planes retaining their previous axes, including offset-plane expressions.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A component can carry its own part properties: `metadata` (`part_number`, `description`,
|
||||
//! `revision`… and other tools' reverse-DNS keys, under the rules of the design's own `metadata`)
|
||||
//! and `properties` (free text, by name), which every occurrence shares and the bill of materials
|
||||
//! (`assembly.bom`) reads. Older designs need no transformation: a component without them has none.
|
||||
//! The last release asks for an update instead of dropping them on save.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A joint keeps the expressions its offset, angle and values were typed as (`exprs`) and works
|
||||
//! them out again whenever the design changes, so it follows the parameters they name. Older
|
||||
//! designs need no transformation: a joint without `exprs` keeps its numbers as before. The last
|
||||
//! release refuses a design saved with this (the newer format number).
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A point joint origin (`Snap::Point`) can save `x`, the direction its frame's x takes (laid
|
||||
//! square to z). Older designs need no transformation: a point origin without `x` takes its x
|
||||
//! from z alone, as before; the last release asks for an update instead of dropping the `x` and
|
||||
//! turning the part.
|
||||
@@ -0,0 +1,6 @@
|
||||
//! A joint can be a relation that ties only some directions or points of its two parts:
|
||||
//! `parallel`, `perpendicular`, `angle` (its `angle` is then the tilt between the two z axes),
|
||||
//! `point_on_plane`, `point_on_line` and `line_on_plane`, each with the values its free motion
|
||||
//! needs. Several relations between one pair of parts close loops the solver meets together.
|
||||
//! Older designs need no transformation; the last release asks for an update instead of meeting
|
||||
//! a joint type it can't read.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A sketch can hold a `line_circle_distance` dimension (#1803): the distance from a line `l` to
|
||||
//! the edge of a circle or arc `c`, saved with its `value` and, when the line cuts the circle,
|
||||
//! `inside` (the value is then how far the edge reaches past the line). Older designs need no
|
||||
//! transformation: they have no such dimension; 0.16.x (which reads up to 27) asks for an update
|
||||
//! instead of meeting a constraint type it doesn't know.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Lofts (`Loft`, `SurfaceLoft`) take guide rails: `rails` (one or two `{sketch, curves}` chains,
|
||||
//! as a sweep's) and `rail_fit` (`scale` or `stretch`). Both default to none and `scale`, so older
|
||||
//! designs read unchanged.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A sketch point can be placed by parameters: `exprs` (`x` and/or `y`, expressions over the
|
||||
//! design's parameters, in the design's units when a number in them has no unit) on a point that
|
||||
//! is `fixed` where they come to, and moved there again before each solve when the parameters
|
||||
//! change. Older designs need no transformation: their points have no `exprs`. The last release
|
||||
//! asks for an update instead of opening a design with this format number.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A `SurfaceBlend` (Blend Surface, #764) may save `seam`: a point on each of its two edge chains
|
||||
//! where their stations start. Older designs need no transformation: a blend without `seam` pairs
|
||||
//! its chains by itself; the last release asks for an update instead of meeting a seam it can't
|
||||
//! read.
|
||||
@@ -0,0 +1,6 @@
|
||||
//! A `SurfaceTrim` can keep several regions (`keep_more`: more points; with them, the pieces that
|
||||
//! hang together once cut that `keep` and these points pick are kept instead of the side of
|
||||
//! `keep`), cut with sketch curves swept onto the surface (`curves`: `sketch`, optional `curves`
|
||||
//! ids and `direction`), or trim another surface and be trimmed by it (`with`, `keep_with` points,
|
||||
//! optional `stitch` and `extend`). Older designs need no transformation: a trim without them
|
||||
//! saves as before; 0.14.x asks for an update instead of ignoring them and trimming by the plane.
|
||||
@@ -0,0 +1,49 @@
|
||||
//! A two-sided extrude (`distance2`) follows its `direction` (#2700): with `direction: negative`
|
||||
//! the first distance goes against the sketch normal and `distance2` along it, so either side can
|
||||
//! be the through-all or To Object one. Older builds ignored `direction` when an extrude had a
|
||||
//! second distance (the first always went along the normal), so this step makes the two-sided
|
||||
//! `negative` extrudes of older designs `positive`, which keeps their shape. 0.15.x asks for an
|
||||
//! update instead of opening a design with a reversed two-sided extrude.
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn step(v: &mut Value) -> crate::Result<()> {
|
||||
let Some(Value::Array(features)) = v.get_mut("features") else {
|
||||
return Ok(());
|
||||
};
|
||||
for feature in features {
|
||||
if !matches!(feature.get("type").and_then(Value::as_str), Some("extrude" | "surface_extrude")) {
|
||||
continue;
|
||||
}
|
||||
if let Some(extent) = feature.get_mut("extent").and_then(Value::as_object_mut)
|
||||
&& extent.get("distance2").is_some_and(|d| !d.is_null())
|
||||
&& extent.get("direction").and_then(Value::as_str) == Some("negative")
|
||||
{
|
||||
extent.insert("direction".into(), Value::from("positive"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn older_reversed_two_sided_extrudes_keep_their_shape() {
|
||||
let mut v = json!({"features": [
|
||||
{"type": "extrude", "extent": {"distance": "10 mm", "direction": "negative", "distance2": "5 mm"}},
|
||||
{"type": "surface_extrude", "extent": {"distance": "10 mm", "direction": "negative", "distance2": "5 mm"}},
|
||||
{"type": "extrude", "extent": {"distance": "10 mm", "direction": "negative"}},
|
||||
{"type": "extrude", "extent": {"distance": "10 mm", "direction": "symmetric", "distance2": "5 mm"}},
|
||||
{"type": "revolve", "extent": {"direction": "negative", "distance2": "5 mm"}},
|
||||
{"type": "sketch"}
|
||||
]});
|
||||
super::step(&mut v).unwrap();
|
||||
let dirs: Vec<Option<&str>> = v["features"].as_array().unwrap().iter().map(|f| f["extent"]["direction"].as_str()).collect();
|
||||
assert_eq!(dirs, [Some("positive"), Some("positive"), Some("negative"), Some("symmetric"), Some("negative"), None]);
|
||||
// A design with no features is left alone.
|
||||
let mut empty = json!({"name": "Untitled"});
|
||||
super::step(&mut empty).unwrap();
|
||||
assert_eq!(empty, json!({"name": "Untitled"}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A construction reference to a body vertex saves the names of the edges that meet there
|
||||
//! (`edges` beside `at`), so a plane, axis or point on a vertex follows it when an earlier change
|
||||
//! moves it. Older designs need no transformation: a vertex reference without `edges` is the
|
||||
//! vertex nearest its point, as before. 0.15.x asks for an update instead of reading `edges`.
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Joints between occurrences: joint origins (snap frames on component geometry, re-found on
|
||||
//! every solve so they follow edits), joints (rigid, revolute, slider, cylindrical, pin-slot,
|
||||
//! planar, ball) with their motion values and limits, motion links (gears, racks and screws are
|
||||
//! planar, ball, and relations that tie only part of a placement, `joints_relations.rs`) with
|
||||
//! their motion values and limits, motion links (gears, racks and screws are
|
||||
//! links whose ratio comes from radii or a pitch; a cam's follower follows a table worked out from
|
||||
//! its profile, `joints_cam.rs`), and the solver that places occurrences from the joint graph.
|
||||
//!
|
||||
//! A joint makes B's joint frame coincide with A's frame moved by the joint's motion:
|
||||
//! `T_B · F_b = T_A · F_a · M(q) · Flip`, so `T_B = T_A · F_a · M(q) · Flip · F_b⁻¹`, where `T`
|
||||
//! `T_B · F_b = T_A · F_a · M(q) · Flip · Tail(q)`, so `T_B = T_A · F_a · M(q) · Flip · Tail(q) · F_b⁻¹`
|
||||
//! (`Tail` is a relation's tilt and turns after the mate; the identity for the others), where `T`
|
||||
//! are occurrence world transforms, `F` the joint frames in each component's own frame, and `q`
|
||||
//! the joint's values (angles in radians, distances in mm). The solver walks the graph from
|
||||
//! grounded occurrences (and the root, which never moves). Joints that close a loop (a four-bar
|
||||
@@ -15,7 +17,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
|
||||
use solidcraft_sketch::linalg;
|
||||
|
||||
@@ -74,11 +76,14 @@ pub enum Snap {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
corner: Option<String>,
|
||||
},
|
||||
/// A point, z along `z` (default +Z).
|
||||
/// A point, z along `z` (default +Z) and x toward `x` laid square to z (default: one chosen
|
||||
/// from z alone).
|
||||
Point {
|
||||
point: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
z: Option<Vec3>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
x: Option<Vec3>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -173,7 +178,7 @@ impl Snap {
|
||||
match self {
|
||||
Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()),
|
||||
Snap::FaceCenter { pick, x, .. } | Snap::CircleCenter { pick, x, .. } => pick.is_finite() && x_ok(x),
|
||||
Snap::Point { point, z } => point.is_finite() && z.is_none_or(|z| z.is_finite()),
|
||||
Snap::Point { point, z, x } => point.is_finite() && z.is_none_or(|z| z.is_finite()) && x.is_none_or(|x| x.is_finite()),
|
||||
Snap::Edge { pick, .. } => pick.is_finite(),
|
||||
}
|
||||
}
|
||||
@@ -203,6 +208,20 @@ pub enum JointKind {
|
||||
PinSlot,
|
||||
Planar,
|
||||
Ball,
|
||||
/// B's z stays parallel to A's (against it unless flipped); B moves and turns about z freely.
|
||||
Parallel,
|
||||
/// B's z stays square to A's; B moves freely and turns about either z.
|
||||
Perpendicular,
|
||||
/// B's z stays at the joint's `angle` from mated (about their common x); B moves freely and
|
||||
/// turns about either z.
|
||||
Angle,
|
||||
/// B's origin stays on A's xy plane (`offset` off it); B slides on it and turns any way.
|
||||
PointOnPlane,
|
||||
/// B's origin stays on A's z axis; B slides along it and turns any way.
|
||||
PointOnLine,
|
||||
/// B's z axis lies in A's xy plane (`offset` off it: a cylinder's radius makes it tangent);
|
||||
/// B slides on the plane, turns about A's z and about its own axis.
|
||||
LineOnPlane,
|
||||
}
|
||||
|
||||
impl JointKind {
|
||||
@@ -215,6 +234,12 @@ impl JointKind {
|
||||
"pinslot" => JointKind::PinSlot,
|
||||
"planar" => JointKind::Planar,
|
||||
"ball" => JointKind::Ball,
|
||||
"parallel" => JointKind::Parallel,
|
||||
"perpendicular" | "square" => JointKind::Perpendicular,
|
||||
"angle" => JointKind::Angle,
|
||||
"pointonplane" | "pointonface" => JointKind::PointOnPlane,
|
||||
"pointonline" | "pointonaxis" => JointKind::PointOnLine,
|
||||
"lineonplane" | "lineonface" => JointKind::LineOnPlane,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -228,10 +253,29 @@ impl JointKind {
|
||||
JointKind::PinSlot => &["angle", "slide"],
|
||||
JointKind::Planar => &["x", "y", "angle"],
|
||||
JointKind::Ball => &["yaw", "pitch", "roll"],
|
||||
JointKind::Parallel => &["x", "y", "z", "angle"],
|
||||
JointKind::Perpendicular | JointKind::Angle => &["x", "y", "z", "turn", "spin"],
|
||||
JointKind::PointOnPlane => &["x", "y", "yaw", "pitch", "roll"],
|
||||
JointKind::PointOnLine => &["slide", "yaw", "pitch", "roll"],
|
||||
JointKind::LineOnPlane => &["x", "y", "turn", "spin"],
|
||||
}
|
||||
}
|
||||
pub fn is_angle(self, i: usize) -> bool {
|
||||
matches!(self.dofs().get(i), Some(&("angle" | "yaw" | "pitch" | "roll")))
|
||||
matches!(self.dofs().get(i), Some(&("angle" | "yaw" | "pitch" | "roll" | "turn" | "spin")))
|
||||
}
|
||||
/// A relation that only ties some directions or points (parallel, on a plane…): made where
|
||||
/// the parts are, its values start as the nearest that meet it, so B moves no more than it
|
||||
/// must.
|
||||
pub fn is_relation(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
JointKind::Parallel
|
||||
| JointKind::Perpendicular
|
||||
| JointKind::Angle
|
||||
| JointKind::PointOnPlane
|
||||
| JointKind::PointOnLine
|
||||
| JointKind::LineOnPlane
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +304,92 @@ pub struct Joint {
|
||||
pub angle: f64,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub suppressed: bool,
|
||||
/// The expressions the offset, the angle and the values were typed as: they are worked out
|
||||
/// again whenever the design changes, so a joint follows the parameters it is defined by.
|
||||
#[serde(default, skip_serializing_if = "JointExprs::is_empty")]
|
||||
pub exprs: JointExprs,
|
||||
}
|
||||
|
||||
/// Expressions a joint's numbers follow (over the design's parameters): the offset (a length),
|
||||
/// the angle and each value (angles or lengths as the joint's values are). A number typed in
|
||||
/// place of one drops it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct JointExprs {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub angle: Option<String>,
|
||||
/// By value index, as in `Joint::values`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub values: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
impl JointExprs {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.offset.is_none() && self.angle.is_none() && self.values.iter().all(Option::is_none)
|
||||
}
|
||||
|
||||
/// The expression value `i` follows.
|
||||
pub fn value(&self, i: usize) -> Option<&str> {
|
||||
self.values.get(i).and_then(Option::as_deref)
|
||||
}
|
||||
|
||||
/// Value `i` follows `e` (or nothing).
|
||||
pub fn set_value(&mut self, i: usize, e: Option<String>) {
|
||||
if e.is_some() && self.values.len() <= i {
|
||||
self.values.resize(i + 1, None);
|
||||
}
|
||||
if let Some(slot) = self.values.get_mut(i) {
|
||||
*slot = e;
|
||||
}
|
||||
while self.values.last().is_some_and(Option::is_none) {
|
||||
self.values.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest offset or value an expression may give a joint (mm, or radians for an angle).
|
||||
const MAX_EXPR_VALUE: f64 = 1e9;
|
||||
|
||||
/// 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>)> {
|
||||
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)))?;
|
||||
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;
|
||||
let offset = match &x.offset {
|
||||
Some(e) => eval(e, crate::expr::Kind::Length, "offset")?,
|
||||
None => j.offset,
|
||||
};
|
||||
let angle = match &x.angle {
|
||||
Some(e) => eval(e, crate::expr::Kind::Angle, "angle")?,
|
||||
None => j.angle,
|
||||
};
|
||||
let mut values = j.values.clone();
|
||||
for (i, e) in x.values.iter().enumerate().take(j.kind.dofs().len()) {
|
||||
let Some(e) = e else { continue };
|
||||
let kind = if j.kind.is_angle(i) { crate::expr::Kind::Angle } else { crate::expr::Kind::Length };
|
||||
let name = j.kind.dofs().get(i).copied().unwrap_or("value");
|
||||
let v = eval(e, kind, name)?;
|
||||
if values.len() <= i {
|
||||
values.resize(i + 1, 0.0);
|
||||
}
|
||||
if let Some(slot) = values.get_mut(i) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
Ok((offset, angle, values))
|
||||
}
|
||||
|
||||
/// Values the loop solver leaves alone: those a motion link sets and those an expression does.
|
||||
fn set_values(asm: &Assembly) -> BTreeSet<(u64, usize)> {
|
||||
let mut set: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
|
||||
for j in &asm.joints {
|
||||
set.extend(j.exprs.values.iter().enumerate().filter(|(_, e)| e.is_some()).map(|(i, _)| (j.id, i)));
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset, or for a
|
||||
@@ -446,6 +576,49 @@ pub struct Assembly {
|
||||
}
|
||||
|
||||
impl Assembly {
|
||||
/// Every expression the joints and motion links keep, with the joint (or the driven joint of
|
||||
/// a link) and what it sets: `offset`, `angle`, a value's name, or a coupling's length.
|
||||
pub fn exprs(&self) -> Vec<(u64, String, &str)> {
|
||||
let mut out = Vec::new();
|
||||
for j in &self.joints {
|
||||
let x = &j.exprs;
|
||||
out.extend(x.offset.as_deref().map(|e| (j.id, "offset".to_string(), e)));
|
||||
out.extend(x.angle.as_deref().map(|e| (j.id, "angle".to_string(), e)));
|
||||
for (i, e) in x.values.iter().enumerate() {
|
||||
if let Some(e) = e {
|
||||
out.push((j.id, j.kind.dofs().get(i).copied().unwrap_or("value").to_string(), e.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in &self.links {
|
||||
for s in l.coupling.as_ref().map(Coupling::sizes).unwrap_or_default() {
|
||||
if let Size::Expr(e) = s {
|
||||
out.push((l.b, "coupling".to_string(), e.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The expressions of `exprs`, to rewrite (a renamed parameter).
|
||||
pub fn exprs_mut(&mut self) -> Vec<&mut String> {
|
||||
let mut out = Vec::new();
|
||||
for j in &mut self.joints {
|
||||
let x = &mut j.exprs;
|
||||
out.extend(x.offset.as_mut());
|
||||
out.extend(x.angle.as_mut());
|
||||
out.extend(x.values.iter_mut().flatten());
|
||||
}
|
||||
for l in &mut self.links {
|
||||
for s in l.coupling.as_mut().map(Coupling::sizes_mut).unwrap_or_default() {
|
||||
if let Size::Expr(e) = s {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.joints.is_empty()
|
||||
&& self.origins.is_empty()
|
||||
@@ -470,6 +643,10 @@ fn rot_z(a: f64) -> Mat {
|
||||
let (s, c) = a.sin_cos();
|
||||
[[c, s, 0.0, 0.0], [-s, c, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]
|
||||
}
|
||||
fn rot_x(a: f64) -> Mat {
|
||||
let (s, c) = a.sin_cos();
|
||||
[[1.0, 0.0, 0.0, 0.0], [0.0, c, s, 0.0], [0.0, -s, c, 0.0], [0.0, 0.0, 0.0, 1.0]]
|
||||
}
|
||||
fn rot_y(a: f64) -> Mat {
|
||||
let (s, c) = a.sin_cos();
|
||||
[[c, 0.0, -s, 0.0], [0.0, 1.0, 0.0, 0.0], [s, 0.0, c, 0.0], [0.0, 0.0, 0.0, 1.0]]
|
||||
@@ -491,9 +668,40 @@ pub fn motion(kind: JointKind, q: &[f64]) -> Mat {
|
||||
JointKind::PinSlot => mat_mul(&shift(v(1), 0.0, 0.0), &rot_z(v(0))),
|
||||
JointKind::Planar => mat_mul(&shift(v(0), v(1), 0.0), &rot_z(v(2))),
|
||||
JointKind::Ball => mat_mul(&mat_mul(&rot_z(v(0)), &rot_y(v(1))), &rot_z(v(2))),
|
||||
JointKind::Parallel => mat_mul(&shift(v(0), v(1), v(2)), &rot_z(v(3))),
|
||||
JointKind::Perpendicular | JointKind::Angle => mat_mul(&shift(v(0), v(1), v(2)), &rot_z(v(3))),
|
||||
// Their turn is about B's origin, after the offset (`tail`).
|
||||
JointKind::PointOnPlane => shift(v(0), v(1), 0.0),
|
||||
JointKind::PointOnLine => shift(0.0, 0.0, v(0)),
|
||||
JointKind::LineOnPlane => mat_mul(&shift(v(0), v(1), 0.0), &rot_z(v(2))),
|
||||
}
|
||||
}
|
||||
|
||||
fn turn_zyx(yaw: f64, pitch: f64, roll: f64) -> Mat {
|
||||
mat_mul(&mat_mul(&rot_z(yaw), &rot_y(pitch)), &rot_x(roll))
|
||||
}
|
||||
|
||||
/// What follows the mate (`flip_of`) in a joint's link: for a relation that tilts B's z, the tilt
|
||||
/// and B's turn about its own z (`spin`); for a point relation, B's turn about its origin (yaw,
|
||||
/// pitch and roll about z, y and x: free of gimbal lock at the start, all 0); nothing for the
|
||||
/// other joints.
|
||||
fn tail(j: &Joint, q: &[f64]) -> Mat {
|
||||
let v = |i: usize| q.get(i).copied().unwrap_or(0.0);
|
||||
match j.kind {
|
||||
JointKind::Perpendicular => mat_mul(&rot_x(std::f64::consts::FRAC_PI_2), &rot_z(v(4))),
|
||||
JointKind::Angle => mat_mul(&rot_x(j.angle), &rot_z(v(4))),
|
||||
JointKind::LineOnPlane => mat_mul(&rot_y(std::f64::consts::FRAC_PI_2), &rot_z(v(3))),
|
||||
JointKind::PointOnPlane => turn_zyx(v(2), v(3), v(4)),
|
||||
JointKind::PointOnLine => turn_zyx(v(1), v(2), v(3)),
|
||||
_ => IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// B's joint frame in A's for the joint's values: `M(q) · Flip · tail`.
|
||||
fn link_of(j: &Joint, q: &[f64]) -> Mat {
|
||||
mat_mul(&mat_mul(&motion(j.kind, q), &flip_of(j)), &tail(j, q))
|
||||
}
|
||||
|
||||
/// Bodies of a component in the model (its own frame).
|
||||
fn component_bodies<'a>(doc: &Document, st: &'a ModelState, component: u64) -> Vec<&'a crate::ModelBody> {
|
||||
st.bodies.iter().filter(|b| doc.model_body_component(b) == component).collect()
|
||||
@@ -544,7 +752,7 @@ pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Resul
|
||||
let bad = || DocError::Invalid("joint origin: degenerate frame".into());
|
||||
match &o.snap {
|
||||
Snap::Frame { origin, z, x } => frame(*origin, *z, *x).ok_or_else(bad),
|
||||
Snap::Point { point, z } => frame(*point, z.unwrap_or(Vec3::Z), None).ok_or_else(bad),
|
||||
Snap::Point { point, z, x } => frame(*point, z.unwrap_or(Vec3::Z), *x).ok_or_else(bad),
|
||||
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.
|
||||
@@ -641,7 +849,10 @@ pub struct Solution {
|
||||
pub conflicts: Vec<(u64, String, String)>,
|
||||
/// Joints that could not be applied (bad origin…): (joint id, name, why).
|
||||
pub errors: Vec<(u64, String, String)>,
|
||||
/// Remaining degrees of freedom per occurrence (6 = free, 0 = fixed).
|
||||
/// Remaining degrees of freedom per occurrence (6 = free, 0 = fixed): how many independent
|
||||
/// values still move it, from the fixed occurrences, with loops kept closed and a value a
|
||||
/// motion link sets moving with the values its link chain starts from (a driven gear has its
|
||||
/// driving gear's one, a screw's travel its turn's).
|
||||
pub dof: BTreeMap<u64, usize>,
|
||||
/// Occurrences fixed in place (grounded, or the root's).
|
||||
pub fixed: Vec<u64>,
|
||||
@@ -725,14 +936,16 @@ pub fn joint_values(asm: &Assembly) -> BTreeMap<u64, Vec<f64>> {
|
||||
/// The turn and offset from A's joint frame to B's: mated (B's z against A's, as faces meet)
|
||||
/// unless flipped.
|
||||
fn flip_of(j: &Joint) -> Mat {
|
||||
let mut m = rot_z(j.angle);
|
||||
// An angle relation's `angle` is its tilt (`tail`); a perpendicular one has none.
|
||||
let turn = if matches!(j.kind, JointKind::Angle | JointKind::Perpendicular) { 0.0 } else { j.angle };
|
||||
let mut m = rot_z(turn);
|
||||
m[3][2] = j.offset;
|
||||
if !j.flip { mat_mul(&m, &[[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } else { m }
|
||||
}
|
||||
|
||||
/// B's world transform from A's (or A's from B's, going the other way) for the joint's values.
|
||||
fn through(j: &Joint, (fa, fb): &(Mat, Mat), q: &[f64], from_a: bool, t: &Mat) -> Option<Mat> {
|
||||
let link = mat_mul(&mat_mul(&motion(j.kind, q), &flip_of(j)), &mat_inverse(fb)?);
|
||||
let link = mat_mul(&link_of(j, q), &mat_inverse(fb)?);
|
||||
if from_a {
|
||||
Some(mat_mul(&mat_mul(t, fa), &link))
|
||||
} else {
|
||||
@@ -799,6 +1012,10 @@ impl Tree<'_> {
|
||||
/// on at most: larger ones are only checked.
|
||||
const MAX_LOOP_VALUES: usize = 64;
|
||||
const MAX_LOOPS: usize = 100;
|
||||
/// Loop-closing joints one solve takes on at most, over all its groups: a part placed by several
|
||||
/// relations closes a loop with each one after the first, so a panel of parts has many small
|
||||
/// groups.
|
||||
const MAX_CLOSING: usize = 5_000;
|
||||
/// Residual evaluations one solve may spend on all its loops.
|
||||
const MAX_LOOP_WORK: usize = 200_000;
|
||||
|
||||
@@ -832,6 +1049,46 @@ fn link_chains(asm: &Assembly, tree: &Tree, around: &BTreeSet<u64>, linked: &BTr
|
||||
(feeds, starts)
|
||||
}
|
||||
|
||||
/// The values that move each value a motion link sets: the values its link chain starts from,
|
||||
/// which no link sets (none for a chain that comes back round on itself, which only a hand-edited
|
||||
/// design has). A driven gear's turn is its driving gear's: one degree of freedom, not two.
|
||||
fn link_sources(asm: &Assembly) -> BTreeMap<(u64, usize), BTreeSet<(u64, usize)>> {
|
||||
let mut into: BTreeMap<(u64, usize), Vec<(u64, usize)>> = BTreeMap::new();
|
||||
for l in &asm.links {
|
||||
into.entry((l.b, l.ib)).or_default().push((l.a, l.ia));
|
||||
}
|
||||
let mut sources = BTreeMap::new();
|
||||
if into.is_empty() {
|
||||
return sources;
|
||||
}
|
||||
// A link from a value its joint doesn't have (a hand-edited design) moves nothing.
|
||||
let real: BTreeSet<(u64, usize)> = asm.joints.iter().flat_map(|j| (0..j.kind.dofs().len()).map(|i| (j.id, i))).collect();
|
||||
for &v in into.keys() {
|
||||
// Back along the links, as far as a link chain carries a value (`MAX_LINK_PASSES`).
|
||||
let (mut from, mut seen) = (BTreeSet::new(), BTreeSet::from([v]));
|
||||
let mut frontier = vec![v];
|
||||
for _ in 0..MAX_LINK_PASSES {
|
||||
let mut next = Vec::new();
|
||||
for w in frontier.iter().filter_map(|w| into.get(w)).flatten() {
|
||||
if !seen.insert(*w) {
|
||||
continue;
|
||||
}
|
||||
if into.contains_key(w) {
|
||||
next.push(*w);
|
||||
} else if real.contains(w) {
|
||||
from.insert(*w);
|
||||
}
|
||||
}
|
||||
if next.is_empty() {
|
||||
break;
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
sources.insert(v, from);
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
/// The held values among `touches` that move: (joint, index, from, to), `from` within the
|
||||
/// joint's limits. A suppressed joint still sets values through its motion links.
|
||||
fn held_moves(asm: &Assembly, held: &Held, touches: &BTreeSet<(u64, usize)>, vals: &Values) -> Vec<(u64, usize, f64, f64)> {
|
||||
@@ -855,13 +1112,13 @@ fn held_moves(asm: &Assembly, held: &Held, touches: &BTreeSet<(u64, usize)>, val
|
||||
/// moves; a group that can't close without moving is left to the conflict check).
|
||||
fn close_loops(tree: &Tree, closing: &[u64], asm: &Assembly, held: &Held, vals: &mut Values) -> (Values, BTreeSet<u64>) {
|
||||
let (mut changed, mut stuck) = (BTreeMap::new(), BTreeSet::new());
|
||||
if closing.len() > MAX_LOOPS {
|
||||
if closing.len() > MAX_CLOSING {
|
||||
return (Values::new(), stuck);
|
||||
}
|
||||
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
|
||||
let linked = set_values(asm);
|
||||
let groups = loop_groups(tree, closing, asm, &linked);
|
||||
let spent = std::cell::Cell::new(0usize);
|
||||
for (group, around, touches) in &groups {
|
||||
for (group, around, touches) in groups.iter().filter(|(c, _, _)| c.len() <= MAX_LOOPS) {
|
||||
let moves = held_moves(asm, held, touches, vals);
|
||||
match close_group(tree, group, around, &moves, asm, held, &linked, &spent, vals) {
|
||||
Some(m) => changed.extend(m),
|
||||
@@ -952,15 +1209,14 @@ fn limit_bounds(tree: &Tree, vars: &[(u64, usize)]) -> Vec<(f64, f64)> {
|
||||
/// The loops' size: the diagonal of the box around their joints' A frames, 1 mm to 1 km. Points
|
||||
/// that far apart weigh turns like moves.
|
||||
fn loop_scale(tree: &Tree, around: &BTreeSet<u64>, vals: &Values) -> f64 {
|
||||
let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN));
|
||||
for id in around {
|
||||
let (Some(j), Some((fa, _))) = (tree.joints.get(id), tree.frames.get(id)) else { continue };
|
||||
let Some(t) = tree.world(j.a.occurrence, vals) else { continue };
|
||||
let p = apply_point(&mat_mul(&t, fa), Vec3::ZERO);
|
||||
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
|
||||
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
|
||||
}
|
||||
if lo.x <= hi.x { (hi - lo).len().clamp(1.0, 1e6) } else { 1.0 }
|
||||
let joints: Aabb3 = around
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let (j, (fa, _)) = (tree.joints.get(id)?, tree.frames.get(id)?);
|
||||
Some(apply_point(&mat_mul(&tree.world(j.a.occurrence, vals)?, fa), Vec3::ZERO))
|
||||
})
|
||||
.collect();
|
||||
if joints.is_empty() { 1.0 } else { joints.diagonal().clamp(1.0, 1e6) }
|
||||
}
|
||||
|
||||
/// How far each closing joint is from meeting: three points of B's joint frame, `scale` apart,
|
||||
@@ -1050,11 +1306,12 @@ fn nearby_pose(tree: &Tree, groups: &[Group], asm: &Assembly, linked: &BTreeSet<
|
||||
/// pose alone counts 0. The tree's count stands where a loop group is over the solver's caps or
|
||||
/// the work runs out.
|
||||
fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &mut BTreeMap<u64, usize>) {
|
||||
if closing.len() > MAX_LOOPS {
|
||||
if closing.len() > MAX_CLOSING {
|
||||
return;
|
||||
}
|
||||
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
|
||||
let groups = loop_groups(tree, closing, asm, &linked);
|
||||
let linked = set_values(asm);
|
||||
let mut groups = loop_groups(tree, closing, asm, &linked);
|
||||
groups.retain(|(c, _, _)| c.len() <= MAX_LOOPS);
|
||||
// The joints loops depend on: those around them, those whose values they read, and those a
|
||||
// motion link carries any of these values into.
|
||||
let mut relevant: BTreeSet<u64> = groups.iter().flat_map(|(_, a, t)| a.iter().copied().chain(t.iter().map(|(j, _)| *j))).collect();
|
||||
@@ -1463,10 +1720,28 @@ struct Built<'a> {
|
||||
fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Solution) -> Built<'a> {
|
||||
let asm = &doc.assembly;
|
||||
let joints: Vec<&Joint> = asm.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect();
|
||||
// Joint frames in component coordinates (errors leave the joint out).
|
||||
// Joint frames in component coordinates (errors leave the joint out). An origin several joints
|
||||
// share (a panel face every part sits on) is found once.
|
||||
let mut frames: BTreeMap<u64, (Mat, Mat)> = BTreeMap::new();
|
||||
let mut found: BTreeMap<(u64, String), std::result::Result<Mat, String>> = BTreeMap::new();
|
||||
let mut resolve = |o: &JointOrigin| -> Result<Mat> {
|
||||
let Ok(comp) = component_of(doc, o.occurrence) else { return resolve_origin(doc, st, o) };
|
||||
found
|
||||
.entry((comp, format!("{:?}", o.snap)))
|
||||
.or_insert_with(|| resolve_origin(doc, st, o).map_err(|e| e.to_string()))
|
||||
.clone()
|
||||
.map_err(DocError::Invalid)
|
||||
};
|
||||
for j in &joints {
|
||||
match (resolve_origin(doc, st, &j.a), resolve_origin(doc, st, &j.b)) {
|
||||
// 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)
|
||||
{
|
||||
sol.errors.push((j.id, j.name.clone(), e.to_string()));
|
||||
continue;
|
||||
}
|
||||
match (resolve(&j.a), resolve(&j.b)) {
|
||||
(Ok(fa), Ok(fb)) => {
|
||||
frames.insert(j.id, (fa, fb));
|
||||
}
|
||||
@@ -1495,6 +1770,14 @@ fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Soluti
|
||||
Tree { joints: asm.joints.iter().map(|j| (j.id, j)).collect(), frames, fixed: placed.clone(), parent: BTreeMap::new(), order: Vec::new() };
|
||||
let mut used: BTreeSet<u64> = BTreeSet::new();
|
||||
let mut queue: VecDeque<u64> = placed.keys().copied().collect();
|
||||
// An occurrence's degrees of freedom are the distinct values that move it: those of the
|
||||
// joints on its path, a value a motion link sets standing for the values its link chain
|
||||
// starts from (a driven gear turns with its driving gear's one value). Kept per occurrence as
|
||||
// a count of the values no link reads or sets, which no other path value can repeat, and the
|
||||
// set of the ones that may be repeated (links' sources).
|
||||
let sources = link_sources(asm);
|
||||
let shared: BTreeSet<(u64, usize)> = sources.values().flatten().copied().collect();
|
||||
let mut moved_by: BTreeMap<u64, (usize, BTreeSet<(u64, usize)>)> = BTreeMap::new();
|
||||
// The joints at each occurrence, in the joints' order (a large assembly is not walked joint by
|
||||
// joint for every occurrence).
|
||||
let mut at: BTreeMap<u64, Vec<&Joint>> = BTreeMap::new();
|
||||
@@ -1525,7 +1808,18 @@ fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Soluti
|
||||
placed.insert(other, m);
|
||||
tree.parent.insert(other, (occ, j.id, from_a));
|
||||
tree.order.push(other);
|
||||
dof.insert(other, dof.get(&occ).copied().unwrap_or(0) + j.kind.dofs().len());
|
||||
let (mut own, mut common) = moved_by.get(&occ).cloned().unwrap_or_default();
|
||||
for v in (0..j.kind.dofs().len()).map(|i| (j.id, i)) {
|
||||
match sources.get(&v) {
|
||||
Some(from) => common.extend(from.iter().copied()),
|
||||
None if shared.contains(&v) => {
|
||||
common.insert(v);
|
||||
}
|
||||
None => own += 1,
|
||||
}
|
||||
}
|
||||
dof.insert(other, own + common.len());
|
||||
moved_by.insert(other, (own, common));
|
||||
queue.push_back(other);
|
||||
}
|
||||
}
|
||||
@@ -1598,7 +1892,30 @@ mod reach;
|
||||
#[path = "joints_face.rs"]
|
||||
mod face;
|
||||
pub use face::{end_faces, face_name_at, named_face_spot, pin_end, round_end_at};
|
||||
#[path = "joints_relations.rs"]
|
||||
mod relations;
|
||||
pub use reach::{REACH_TOL, Reach, Reached, reach};
|
||||
pub use relations::relation_values;
|
||||
|
||||
/// The rest angle that turns B least as joint `j` is made, as B sits now: none or a half turn
|
||||
/// about the joint's z. Origins whose x is chosen for them (a face, a circle, a point without
|
||||
/// x) get opposite x when their z are opposite, so two faces that already meet would, at angle
|
||||
/// 0, spin B a half turn about their shared normal.
|
||||
pub fn least_turn_angle(doc: &Document, st: &ModelState, j: &Joint) -> f64 {
|
||||
let (Ok(fa), Ok(fb)) = (resolve_origin(doc, st, &j.a), resolve_origin(doc, st, &j.b)) else { return 0.0 };
|
||||
let (ta, tb) = (occurrence_world(doc, j.a.occurrence), occurrence_world(doc, j.b.occurrence));
|
||||
let mut q = j.values.clone();
|
||||
q.resize(j.kind.dofs().len(), 0.0);
|
||||
clamp(j, &mut q);
|
||||
let turn = |angle: f64| {
|
||||
let k = Joint { angle, ..j.clone() };
|
||||
through(&k, &(fa, fb), &q, true, &ta).map(|m| mismatch(&m, &tb).1)
|
||||
};
|
||||
match (turn(0.0), turn(std::f64::consts::PI)) {
|
||||
(Some(none), Some(half)) if half + 1e-9 < none => std::f64::consts::PI,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame matrix for an explicit origin / z / x (public for commands).
|
||||
pub fn frame_of(o: Vec3, z: Vec3, x: Option<Vec3>) -> Option<Mat> {
|
||||
@@ -1617,7 +1934,7 @@ mod tests {
|
||||
/// Revolute joints 1…n (joint 1 at `start`, the others at 0) linked 1 → 2 → … → n with
|
||||
/// ratio 2, the links stored from the end of the chain; joint 3 limited to ±50.
|
||||
fn chain(n: u64, start: f64) -> Assembly {
|
||||
let origin = JointOrigin { occurrence: 0, snap: Snap::Point { point: Vec3::ZERO, z: None } };
|
||||
let origin = JointOrigin { occurrence: 0, snap: Snap::Point { point: Vec3::ZERO, z: None, x: None } };
|
||||
let joints = (1..=n)
|
||||
.map(|id| Joint {
|
||||
id,
|
||||
@@ -1631,6 +1948,7 @@ mod tests {
|
||||
offset: 0.0,
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
})
|
||||
.collect();
|
||||
let links =
|
||||
@@ -1638,6 +1956,26 @@ mod tests {
|
||||
Assembly { joints, links, ..Assembly::default() }
|
||||
}
|
||||
|
||||
/// A linked value moves with the free value its chain starts from, however the chain is
|
||||
/// stored; a chain that comes back round on itself, or a link from a value its joint lacks
|
||||
/// (hand-edited designs), moves it with nothing.
|
||||
#[test]
|
||||
fn linked_values_move_with_their_chains_start() {
|
||||
let mut asm = chain(5, 0.0);
|
||||
let sources = link_sources(&asm);
|
||||
for b in 2..=5 {
|
||||
assert_eq!(sources.get(&(b, 0)), Some(&BTreeSet::from([(1, 0)])), "J{b}: {sources:?}");
|
||||
}
|
||||
assert!(!sources.contains_key(&(1, 0)));
|
||||
let link = |a: u64, ia: usize, b: u64| MotionLink { a, ia, b, ib: 0, ratio: 1.0, offset: 0.0, coupling: None, lift: Vec::new() };
|
||||
asm.links.push(link(5, 0, 1));
|
||||
assert!(link_sources(&asm).values().all(BTreeSet::is_empty), "{:?}", link_sources(&asm));
|
||||
let mut asm = chain(2, 0.0);
|
||||
asm.links = vec![link(1, 7, 2)];
|
||||
assert_eq!(link_sources(&asm).get(&(2, 0)), Some(&BTreeSet::new()));
|
||||
assert!(link_sources(&Assembly::default()).is_empty());
|
||||
}
|
||||
|
||||
/// Links carry a value down a chain stored from its end, each linked value within its limits
|
||||
/// as it is set, and applying them again changes nothing.
|
||||
#[test]
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::{Vec2, Vec3};
|
||||
use solidcraft_geom::{Aabb2, Aabb3, Ring, Vec2, Vec3};
|
||||
|
||||
use super::{JointKind, JointOrigin, MotionLink, Size, component_bodies, component_of, flip_of, occurrence_world, size_of};
|
||||
use crate::{DocError, Document, IDENTITY, Mat, ModelState, Result, apply_point, apply_vector, mat_inverse, mat_mul};
|
||||
@@ -108,7 +108,7 @@ fn rot(v: Vec2, a: f64) -> Vec2 {
|
||||
/// Where a translating follower coming along `-d` from far along its line through `g` first
|
||||
/// touches the closed polygon `poly`: the largest `t` of its point `g + t·d` (its roller's centre
|
||||
/// for `r > 0`; for `flat`, its face square to `d`). `None` when it misses the profile.
|
||||
pub fn first_contact_along(poly: &[Vec2], g: Vec2, d: Vec2, r: f64, flat: bool) -> Option<f64> {
|
||||
pub fn first_contact_along(poly: &Ring<Vec2>, g: Vec2, d: Vec2, r: f64, flat: bool) -> Option<f64> {
|
||||
let n = d.perp();
|
||||
let mut best: Option<f64> = None;
|
||||
let mut take = |t: f64| {
|
||||
@@ -122,9 +122,7 @@ pub fn first_contact_along(poly: &[Vec2], g: Vec2, d: Vec2, r: f64, flat: bool)
|
||||
}
|
||||
return best;
|
||||
}
|
||||
let count = poly.len();
|
||||
for i in 0..count {
|
||||
let (Some(&p), Some(&q)) = (poly.get(i), poly.get((i + 1) % count)) else { continue };
|
||||
for (&p, &q) in poly.edges() {
|
||||
let (a, b) = ((p - g).dot(n), (q - g).dot(n));
|
||||
// Edges wholly outside the band the follower sweeps.
|
||||
if a.min(b) > r || a.max(b) < -r {
|
||||
@@ -167,10 +165,8 @@ pub fn first_contact_along(poly: &[Vec2], g: Vec2, d: Vec2, r: f64, flat: bool)
|
||||
/// The angles in [0, 2π) where an oscillating follower's tip touches the closed polygon `poly`:
|
||||
/// its tip (a roller's centre for `r > 0`) at `arm` from the pivot `pivot`, turned from the angle
|
||||
/// `start` the way `turn` says (+1 counter-clockwise), sorted.
|
||||
fn touches_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start: f64, turn: f64) -> Vec<f64> {
|
||||
fn touches_turning(poly: &Ring<Vec2>, pivot: Vec2, arm: f64, r: f64, start: f64, turn: f64) -> Vec<f64> {
|
||||
let tip = |a: f64| pivot + Vec2::from_angle(a) * arm;
|
||||
let count = poly.len();
|
||||
let edge = |i: usize| Some((*poly.get(i)?, *poly.get((i + 1) % count)?));
|
||||
let mut out = Vec::new();
|
||||
let mut take = |a: f64, keep: &dyn Fn(Vec2) -> bool| {
|
||||
let turned = (turn * (a - start)).rem_euclid(TAU);
|
||||
@@ -187,7 +183,7 @@ fn touches_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start: f64, tur
|
||||
let (base, spread) = (w.angle(), (h / len).clamp(-1.0, 1.0).acos());
|
||||
vec![base + spread, base - spread]
|
||||
};
|
||||
for (p, q) in (0..count).filter_map(edge) {
|
||||
for (&p, &q) in poly.edges() {
|
||||
let near = dist_to_segment(pivot, p, q);
|
||||
let far = (p - pivot).len().max((q - pivot).len());
|
||||
if near > arm + r || far < arm - r {
|
||||
@@ -216,19 +212,17 @@ fn touches_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start: f64, tur
|
||||
|
||||
/// Whether an oscillating follower's tip, turned `turned` from `start` the way `turn` says,
|
||||
/// overlaps the profile by more than `slack`.
|
||||
fn overlaps_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, (start, turn): (f64, f64), turned: f64, slack: f64) -> bool {
|
||||
fn overlaps_turning(poly: &Ring<Vec2>, pivot: Vec2, arm: f64, r: f64, (start, turn): (f64, f64), turned: f64, slack: f64) -> bool {
|
||||
let at = pivot + Vec2::from_angle(start + turn * turned) * arm;
|
||||
let n = poly.len();
|
||||
let gap =
|
||||
(0..n).filter_map(|i| Some((*poly.get(i)?, *poly.get((i + 1) % n)?))).map(|(p, q)| dist_to_segment(at, p, q)).fold(f64::INFINITY, f64::min);
|
||||
let gap = poly.edges().map(|(p, q)| dist_to_segment(at, *p, *q)).fold(f64::INFINITY, f64::min);
|
||||
// Inside counts only clear of the boundary: a knife on an edge touches it.
|
||||
gap < r - slack || (gap > slack && inside(poly, at))
|
||||
gap < r - slack || (gap > slack && poly.contains(at))
|
||||
}
|
||||
|
||||
/// Where an oscillating follower first touches the closed polygon `poly` turning from `start`
|
||||
/// (the angle it turns through, in (0, 2π)): see `touches_turning`. `None` when it misses the
|
||||
/// profile or starts on it.
|
||||
pub fn first_contact_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start: f64, turn: f64) -> Option<f64> {
|
||||
pub fn first_contact_turning(poly: &Ring<Vec2>, pivot: Vec2, arm: f64, r: f64, start: f64, turn: f64) -> Option<f64> {
|
||||
if overlaps_turning(poly, pivot, arm, r, (start, turn), 0.0, -f64::EPSILON) {
|
||||
return None;
|
||||
}
|
||||
@@ -239,7 +233,7 @@ pub fn first_contact_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, start
|
||||
/// after the cam turned on a little: pushed back to where the profile now lets it be when its tip
|
||||
/// overlaps the profile (by more than `slack`), else on to where it next touches. It stays on the
|
||||
/// part of the profile it rides, however the profile crosses its tip's circle elsewhere.
|
||||
pub fn next_contact_turning(poly: &[Vec2], pivot: Vec2, arm: f64, r: f64, (start, turn): (f64, f64), from: f64, slack: f64) -> Option<f64> {
|
||||
pub fn next_contact_turning(poly: &Ring<Vec2>, pivot: Vec2, arm: f64, r: f64, (start, turn): (f64, f64), from: f64, slack: f64) -> Option<f64> {
|
||||
let touches = touches_turning(poly, pivot, arm, r, start, turn);
|
||||
// A touch where the tip is clear of the rest of the profile (a roller inside the profile is a
|
||||
// radius from the parallels of the edges near the one it overlaps too).
|
||||
@@ -257,23 +251,10 @@ fn dist_to_segment(x: Vec2, p: Vec2, q: Vec2) -> f64 {
|
||||
x.dist(p + d * t)
|
||||
}
|
||||
|
||||
/// Even-odd containment in a closed polygon.
|
||||
fn inside(poly: &[Vec2], x: Vec2) -> bool {
|
||||
let n = poly.len();
|
||||
let mut inside = false;
|
||||
for i in 0..n {
|
||||
let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
|
||||
if (a.y > x.y) != (b.y > x.y) && x.x < a.x + (x.y - a.y) / (b.y - a.y) * (b.x - a.x) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// Everything a cam's table is worked out from, in the cam's fixed frame C.
|
||||
struct Setup {
|
||||
/// The profile at cam value 0.
|
||||
poly: Vec<Vec2>,
|
||||
poly: Ring<Vec2>,
|
||||
/// +1 when the cam turns by +value about C's z (it is the joint's b side), −1 otherwise.
|
||||
turn: f64,
|
||||
/// A translating follower's line (a point and its unit direction, the slider's +z), or an
|
||||
@@ -517,19 +498,11 @@ fn sampled(lp: &solidcraft_geom::Loop2, tol: f64) -> Option<Vec<Vec2>> {
|
||||
|
||||
/// The diagonal of the box around some points (0 for none).
|
||||
fn extent<'a>(pts: impl Iterator<Item = &'a Vec3>) -> f64 {
|
||||
let (lo, hi) = pts.fold((Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)), |(lo, hi), p| {
|
||||
(Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z)), Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z)))
|
||||
});
|
||||
if lo.x <= hi.x { (hi - lo).len() } else { 0.0 }
|
||||
pts.copied().collect::<Aabb3>().diagonal()
|
||||
}
|
||||
|
||||
fn bounds_2d(pts: &[Vec2]) -> f64 {
|
||||
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
|
||||
for p in pts {
|
||||
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
|
||||
}
|
||||
if lo.x <= hi.x { (hi - lo).len() } else { 0.0 }
|
||||
pts.iter().copied().collect::<Aabb2>().diagonal()
|
||||
}
|
||||
|
||||
/// The chord error a profile is sampled to: 0.1 µm per 100 mm of its size, at least 0.1 µm.
|
||||
@@ -569,7 +542,7 @@ fn setup(doc: &Document, st: &ModelState, l: &MotionLink, c: &Cam) -> Result<Set
|
||||
if v.iter().any(|p| (p.z - mean).abs() > 1e-6 * size + 1e-9) {
|
||||
return Err(bad("the profile must lie in a plane square to the cam's axis"));
|
||||
}
|
||||
let poly: Vec<Vec2> = v.iter().map(|p| Vec2::new(p.x, p.y)).collect();
|
||||
let poly: Ring<Vec2> = v.iter().map(|p| Vec2::new(p.x, p.y)).collect();
|
||||
if !poly.iter().all(|p| p.is_finite()) || area(&poly).abs() <= 1e-12 * size * size {
|
||||
return Err(bad("the profile encloses no area"));
|
||||
}
|
||||
@@ -709,9 +682,9 @@ fn follower_is_b(doc: &Document, cam_joint: &super::Joint, fol: &super::Joint, (
|
||||
|
||||
/// A polygon's signed area, from its first point (so a small profile far from the axis keeps its
|
||||
/// digits).
|
||||
fn area(poly: &[Vec2]) -> f64 {
|
||||
let (n, o) = (poly.len(), poly.first().copied().unwrap_or(Vec2::ZERO));
|
||||
(0..n).filter_map(|i| Some((*poly.get(i)? - o).cross(*poly.get((i + 1) % n)? - o))).sum::<f64>() / 2.0
|
||||
fn area(poly: &Ring<Vec2>) -> f64 {
|
||||
let o = poly.first().copied().unwrap_or(Vec2::ZERO);
|
||||
poly.edges().map(|(p, q)| (*p - o).cross(*q - o)).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
/// The follower's places at the samples 0 ..= `CAM_SAMPLES` of a turn, `place(u, prev)` giving
|
||||
@@ -816,7 +789,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A circle of radius `r` about `c` as a polygon of `n` points (its sag is r·(1 − cos π/n)).
|
||||
fn circle(c: Vec2, r: f64, n: usize) -> Vec<Vec2> {
|
||||
fn circle(c: Vec2, r: f64, n: usize) -> Ring<Vec2> {
|
||||
(0..n).map(|k| c + Vec2::from_angle(TAU * k as f64 / n as f64) * r).collect()
|
||||
}
|
||||
|
||||
@@ -874,10 +847,9 @@ mod tests {
|
||||
}
|
||||
// Followed on from where it touches, it stays on the part of the profile it rides: a C
|
||||
// whose two arms both cross the tip's circle, the tip resting on the second arm.
|
||||
let c: Vec<Vec2> = [(-10.0, 20.0), (30.0, 20.0), (30.0, 14.0), (-4.0, 14.0), (-4.0, -14.0), (30.0, -14.0), (30.0, -20.0), (-10.0, -20.0)]
|
||||
.into_iter()
|
||||
.map(|(x, y)| Vec2::new(x, y))
|
||||
.collect();
|
||||
let c = Ring::from(
|
||||
[[-10.0, 20.0], [30.0, 20.0], [30.0, 14.0], [-4.0, 14.0], [-4.0, -14.0], [30.0, -14.0], [30.0, -20.0], [-10.0, -20.0]].map(Vec2::from),
|
||||
);
|
||||
let (pv, l) = (Vec2::new(40.0, 0.0), 22.0);
|
||||
let touches = touches_turning(&c, pv, l, 0.0, 0.0, 1.0);
|
||||
let on = |t: f64| (pv + Vec2::from_angle(t) * l).y;
|
||||
@@ -887,12 +859,12 @@ mod tests {
|
||||
let stay = next_contact_turning(&c, pv, l, 0.0, (0.0, 1.0), second, 1e-9).unwrap_or(f64::NAN);
|
||||
assert!((stay - second).abs() < 1e-12, "{stay} vs {second}");
|
||||
// A knife on an edge touches it, it doesn't overlap it; just inside, it does.
|
||||
let square: Vec<Vec2> = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].into_iter().map(|(x, y)| Vec2::new(x, y)).collect();
|
||||
let square = Ring::from([[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]].map(Vec2::from));
|
||||
let (pv, l) = (Vec2::new(0.0, -4.0), 3.0);
|
||||
let up = std::f64::consts::FRAC_PI_2;
|
||||
assert!(!overlaps_turning(&square, pv, l, 0.0, (up, 1.0), 0.0, 1e-9));
|
||||
assert!(overlaps_turning(&square, pv, l + 0.01, 0.0, (up, 1.0), 0.0, 1e-9));
|
||||
assert!((area(&square.iter().map(|p| *p * 0.004 + Vec2::new(524_288.0, 524_288.0)).collect::<Vec<_>>()) - 6.4e-5).abs() < 1e-12);
|
||||
assert!((area(&square.iter().map(|p| *p * 0.004 + Vec2::new(524_288.0, 524_288.0)).collect::<Ring<_>>()) - 6.4e-5).abs() < 1e-12);
|
||||
// An arm that can't reach, and one that starts on the cam.
|
||||
let poly = circle(Vec2::new(e, 0.0), r, 512);
|
||||
assert_eq!(first_contact_turning(&poly, pivot, 5.0, 0.0, start, 1.0), None);
|
||||
@@ -903,7 +875,7 @@ mod tests {
|
||||
/// comes back after the turn: refused. On the eccentric circle it follows smoothly.
|
||||
#[test]
|
||||
fn a_follower_that_jumps_is_refused() {
|
||||
let arm_round = |poly: &[Vec2], pivot: Vec2, arm: f64| {
|
||||
let arm_round = |poly: &Ring<Vec2>, pivot: Vec2, arm: f64| {
|
||||
let start = pivot.angle();
|
||||
let place = |u: f64, prev: Option<f64>| -> Result<f64> {
|
||||
let q = -TAU * u / CAM_SAMPLES as f64;
|
||||
@@ -916,7 +888,7 @@ mod tests {
|
||||
};
|
||||
round(&place, true, 1e-6)
|
||||
};
|
||||
let lobes: Vec<Vec2> = (0..240)
|
||||
let lobes: Ring<Vec2> = (0..240)
|
||||
.map(|k| {
|
||||
let t = TAU * f64::from(k) / 240.0;
|
||||
Vec2::from_angle(t) * (28.0 + 2.0 / 3.0 + 13.0 * (3.0 * t).cos())
|
||||
@@ -954,7 +926,7 @@ mod tests {
|
||||
/// a guide only on the cam's own side is refused.
|
||||
#[test]
|
||||
fn the_guide_is_the_side_fewer_moving_joints_from_the_frame() {
|
||||
let origin = JointOrigin { occurrence: 0, snap: super::super::Snap::Point { point: Vec3::ZERO, z: None } };
|
||||
let origin = JointOrigin { occurrence: 0, snap: super::super::Snap::Point { point: Vec3::ZERO, z: None, x: None } };
|
||||
let joint = |id: u64, kind: JointKind, a: u64, b: u64| super::super::Joint {
|
||||
id,
|
||||
name: format!("J{id}"),
|
||||
@@ -967,6 +939,7 @@ mod tests {
|
||||
offset: 0.0,
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
};
|
||||
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
|
||||
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.
|
||||
|
||||