mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ddf48ca16 | |||
| d2f6848377 |
@@ -1,14 +0,0 @@
|
||||
# cargo-mutants (docs/mutation-testing.md). Mutants are judged by the black-box suites that will
|
||||
# outlive the kernel backend: the reference tests (tests/oracle, through the oracle_cases test)
|
||||
# and the pull-request sample of the robustness goldens (every 8th seed, as ci.yml). The
|
||||
# kernel's own unit tests are left out on purpose: a mutant only they catch is a behaviour the
|
||||
# backend-neutral suites would miss when the kernel is swapped.
|
||||
profile = "mutants"
|
||||
additional_cargo_test_args = [
|
||||
"--test", "oracle_cases", "--lib", "--", "--include-ignored",
|
||||
"oracle_suite",
|
||||
"robustness_goldens::seed_00", "robustness_goldens::seed_08", "robustness_goldens::seed_16",
|
||||
"robustness_goldens::seed_24", "robustness_goldens::seed_32", "robustness_goldens::seed_40",
|
||||
"robustness_goldens::seed_48", "robustness_goldens::seed_56", "robustness_goldens::seed_64",
|
||||
"robustness_goldens::seed_72", "robustness_goldens::seed_80", "robustness_goldens::seed_88",
|
||||
]
|
||||
+2
-31
@@ -1,9 +1,8 @@
|
||||
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
|
||||
# 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).
|
||||
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
|
||||
|
||||
[profile.ci]
|
||||
# Heavy tests (one to twelve minutes each on a CI runner) that pull requests skip. They run on
|
||||
# Heavy tests (one to four 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.
|
||||
@@ -18,7 +17,6 @@ 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))
|
||||
@@ -41,30 +39,3 @@ 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,8 +2,3 @@ self-hosted-runner:
|
||||
labels:
|
||||
- self-hosted-fast
|
||||
- self-hosted-light
|
||||
- blacksmith-4vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-ubuntu-2404
|
||||
- blacksmith-16vcpu-ubuntu-2404
|
||||
- blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- blacksmith-6vcpu-macos-15
|
||||
|
||||
+78
-161
@@ -5,7 +5,7 @@
|
||||
# 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 shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# Tests are built once and run in 4 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
|
||||
@@ -14,39 +14,26 @@
|
||||
# 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.
|
||||
# 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. Pushes to main, 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.
|
||||
# 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). For pushes to main and the merge queue only, the
|
||||
# repository checks, Clippy, the doc tests, the reference tests and all four test shards run on the
|
||||
# self-hosted runners (`self-hosted-fast`, warm build directories via scripts/ci/warm-target.sh);
|
||||
# a pull request runs them on Blacksmith's runners (blacksmith-<N>vcpu-ubuntu-2404, ephemeral
|
||||
# VMs sized per job), so it never holds up the queue. Every fork PR's Linux jobs run there too.
|
||||
# The self-hosted-untrusted pool (isolated gVisor boxes) is not routed to now; to use it again,
|
||||
# send fork PR jobs to `self-hosted-untrusted` in the `runs-on` of the jobs that had it.
|
||||
# Where the test shards run on Blacksmith's runners, the test archive is built there too: 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). The self-hosted shards
|
||||
# (main and the merge queue) skip the archive and build the tests in their warm build directories.
|
||||
# Blacksmith's runners count as self-hosted to GitHub, so steps tell our machines apart by
|
||||
# runner name (minisforum-*, ci-box-*), not by runner.environment. The Windows jobs run on
|
||||
# GitHub's runners. The nightly run and Run workflow use Blacksmith's runners only; the
|
||||
# self-hosted runners refuse any other job.
|
||||
# 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.
|
||||
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. `labeled` is there for approved-for-ci
|
||||
# only (other labels are skipped, see the top of this file).
|
||||
# marked ready for review, and on every push after that.
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review, labeled]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
# The merge queue: each queued group runs the full suite before it lands on main.
|
||||
merge_group:
|
||||
push:
|
||||
@@ -66,10 +53,7 @@ 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.
|
||||
# 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) || '' }}
|
||||
group: ci-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
@@ -79,7 +63,7 @@ jobs:
|
||||
dependency-advisories:
|
||||
name: Dependency advisories
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -91,11 +75,10 @@ jobs:
|
||||
|
||||
changes:
|
||||
name: Select checks
|
||||
# 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')
|
||||
# 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' || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
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' }}
|
||||
permissions:
|
||||
contents: read
|
||||
# To ask whether the merge queue already tested a commit pushed to main.
|
||||
@@ -106,13 +89,12 @@ 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 }}
|
||||
# The fast self-hosted runners are kept for main and the merge queue (the pre-merge gate);
|
||||
# pull requests, same-repo ones included, run on Blacksmith's runners.
|
||||
local: ${{ (startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) && github.event_name != 'pull_request' }}
|
||||
# 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) }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -125,7 +107,6 @@ jobs:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'approved-for-ci') }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
native=false
|
||||
@@ -202,15 +183,6 @@ jobs:
|
||||
# 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
|
||||
# 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
|
||||
{
|
||||
echo "native=$native"
|
||||
echo "kernel=$kernel"
|
||||
@@ -218,25 +190,25 @@ jobs:
|
||||
echo "windows=$windows"
|
||||
echo "oracle=$oracle"
|
||||
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) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && 'self-hosted-fast' || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
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' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: (startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-'))
|
||||
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
|
||||
# Our self-hosted runners keep their build directory and Cargo home instead.
|
||||
- if: ${{ !(startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) }}
|
||||
# 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
|
||||
with:
|
||||
key: repo-checks
|
||||
@@ -262,42 +234,23 @@ jobs:
|
||||
- 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: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || '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
|
||||
- if: ${{ !(startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) }}
|
||||
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' || 'blacksmith-16vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: (startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-'))
|
||||
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
|
||||
# Our self-hosted runners keep their build directory and Cargo home instead.
|
||||
- if: ${{ !(startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) }}
|
||||
# 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
|
||||
with:
|
||||
key: native-clippy
|
||||
@@ -315,10 +268,8 @@ jobs:
|
||||
build-tests:
|
||||
name: Build tests
|
||||
needs: changes
|
||||
# The self-hosted shards (main and the merge queue) build from their warm build directories
|
||||
# instead: downloading the 3 GB archive took them longer than building.
|
||||
if: needs.changes.outputs.native == 'true' && needs.changes.outputs.local != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
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 }}
|
||||
env:
|
||||
@@ -364,8 +315,7 @@ jobs:
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: [changes, build-tests]
|
||||
if: ${{ !cancelled() && needs.changes.outputs.native == 'true' && (needs.changes.outputs.local == 'true' || needs.build-tests.result == 'success') }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
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') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -382,70 +332,46 @@ jobs:
|
||||
- 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: ${{ !(startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) }}
|
||||
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
|
||||
# On the self-hosted runners each shard builds the tests in its warm build directory (a few
|
||||
# minutes for what changed); elsewhere it runs the archive the Build tests job made.
|
||||
- name: Size parallel compiles to the job's memory
|
||||
if: needs.changes.outputs.local == 'true'
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: needs.changes.outputs.local == 'true'
|
||||
run: scripts/ci/warm-target.sh build-tests
|
||||
- if: needs.changes.outputs.local == 'true'
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- if: needs.changes.outputs.local != 'true'
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
- 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 }}
|
||||
LOCAL: ${{ needs.changes.outputs.local }}
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
PACKAGES: ${{ needs.changes.outputs.packages }}
|
||||
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).
|
||||
filter=()
|
||||
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.
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
# 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)\$/))")
|
||||
all=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
|
||||
fi
|
||||
junit=${CARGO_TARGET_DIR:-target}/nextest/ci/junit.xml
|
||||
# The warm build directory keeps the last run's junit.xml: never upload that one.
|
||||
rm -f "$junit"
|
||||
if [ "$LOCAL" = true ]; then
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
|
||||
run=(cargo nextest run "${scope[@]}" --cargo-profile ci --locked)
|
||||
else
|
||||
# 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.
|
||||
run=(cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite)
|
||||
fi
|
||||
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
|
||||
# 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)"
|
||||
exit 1
|
||||
fi
|
||||
- name: Upload test timings
|
||||
@@ -453,7 +379,7 @@ jobs:
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: junit-${{ matrix.shard }}
|
||||
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
|
||||
path: target/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
action-matrix:
|
||||
@@ -464,7 +390,7 @@ jobs:
|
||||
name: Action matrix (${{ matrix.shard }}/16)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 55
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -506,7 +432,7 @@ jobs:
|
||||
name: Action matrix determinism
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -528,7 +454,7 @@ jobs:
|
||||
name: Property tests (many cases)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -552,7 +478,7 @@ jobs:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -564,11 +490,12 @@ jobs:
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: (startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-'))
|
||||
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
|
||||
# Our self-hosted runners keep their build directory and Cargo home instead.
|
||||
- if: ${{ !(startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) }}
|
||||
# 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
|
||||
with:
|
||||
key: doc-tests
|
||||
@@ -604,7 +531,7 @@ jobs:
|
||||
name: Reference tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.oracle == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
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') }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -616,11 +543,12 @@ jobs:
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: (startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-'))
|
||||
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
|
||||
# Our self-hosted runners keep their build directory and Cargo home instead.
|
||||
- if: ${{ !(startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) }}
|
||||
# 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
|
||||
with:
|
||||
key: oracle
|
||||
@@ -699,21 +627,15 @@ jobs:
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
# On the skipped run another label starts, this job is named differently, so that skipped job
|
||||
# can never stand in for the required `ci` check of the pull request's real run.
|
||||
name: ${{ github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci' && 'ci (ignored label)' || '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) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
needs: [changes, checks, 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' }}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
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 }}
|
||||
LOCAL: ${{ needs.changes.outputs.local }}
|
||||
WINDOWS: ${{ needs.changes.outputs.windows }}
|
||||
ORACLE_RUN: ${{ needs.changes.outputs.oracle }}
|
||||
CLIPPY: ${{ needs.clippy.result }}
|
||||
@@ -726,14 +648,9 @@ 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
|
||||
# The self-hosted test shards build their own tests (no Build tests job there).
|
||||
[ "$BUILD" = success ] || { [ "$LOCAL" = true ] && [ "$BUILD" = skipped ]; }
|
||||
test "$BUILD" = success
|
||||
test "$TESTS" = success
|
||||
test "$DOCS" = success
|
||||
fi
|
||||
@@ -748,7 +665,7 @@ jobs:
|
||||
wasm:
|
||||
name: Compile WebAssembly (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -764,7 +681,7 @@ jobs:
|
||||
riscv64-gates:
|
||||
name: Compile RISC-V (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -27,7 +27,9 @@ concurrency:
|
||||
jobs:
|
||||
claim:
|
||||
name: Claim bot
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
# The bots run main's own scripts (never PR code), so the trusted light runner takes them
|
||||
# and GitHub's shared runners stay free for pull request builds.
|
||||
runs-on: self-hosted-light
|
||||
timeout-minutes: 10
|
||||
if: >-
|
||||
github.event_name != 'issue_comment' ||
|
||||
|
||||
@@ -34,7 +34,7 @@ env:
|
||||
jobs:
|
||||
image:
|
||||
if: ${{ !github.event.repository.private }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -21,7 +21,7 @@ concurrency:
|
||||
jobs:
|
||||
publish:
|
||||
name: Request the combined docs, web, and Flatpak deployment
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
||||
|
||||
@@ -18,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
regenerate:
|
||||
name: Regenerate FreeCAD expected values
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -19,7 +19,7 @@ concurrency:
|
||||
jobs:
|
||||
regenerate:
|
||||
name: Regenerate and commit
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
# Always main itself, never pull request code, since this job can push.
|
||||
|
||||
@@ -14,7 +14,7 @@ permissions:
|
||||
jobs:
|
||||
format-changes:
|
||||
name: Design format changes merge cleanly
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
sorted:
|
||||
name: Lists are sorted one item per line
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -20,7 +20,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
|
||||
@@ -31,7 +31,7 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ !github.event.repository.private }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
@@ -45,7 +45,7 @@ env:
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build: ${{ steps.changed.outputs.build }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
@@ -172,8 +172,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
|
||||
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -210,8 +210,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
|
||||
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
@@ -297,7 +297,7 @@ jobs:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
runs-on: macos-14
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -364,7 +364,7 @@ jobs:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
@@ -392,7 +392,7 @@ jobs:
|
||||
name: GitHub Release or dry-run artifacts
|
||||
needs: [version, linux, flatpak, snap, freebsd, macos, windows, web]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
|
||||
@@ -17,7 +17,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: creds
|
||||
name: Check for Snap Store credentials
|
||||
|
||||
@@ -33,7 +33,9 @@ concurrency:
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage bot
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
# The bots run main's own scripts (never PR code), so the trusted light runner takes them
|
||||
# and GitHub's shared runners stay free for pull request builds.
|
||||
runs-on: self-hosted-light
|
||||
timeout-minutes: 30
|
||||
# Filtered here so the common events (every comment, every label) start no runner.
|
||||
if: >-
|
||||
|
||||
@@ -29,7 +29,7 @@ env:
|
||||
|
||||
jobs:
|
||||
submit:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: check
|
||||
name: Check for the token and the package
|
||||
|
||||
@@ -7,6 +7,3 @@
|
||||
/.claude/worktrees/
|
||||
/target-snapshot
|
||||
__pycache__/
|
||||
|
||||
# cargo-mutants output (docs/mutation-testing.md)
|
||||
mutants.out*/
|
||||
|
||||
@@ -96,7 +96,6 @@ 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 |
|
||||
|
||||
+3
-23
@@ -148,8 +148,6 @@ the most complete description of the house style.
|
||||
A geometry change that changes the robustness goldens re-records them in the same pull request:
|
||||
`ROBUSTNESS_GOLDENS_BLESS="why it changed" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens::`
|
||||
(see [docs/robustness.md](docs/robustness.md)); CI runs every seed on pull requests that touch geometry.
|
||||
[docs/mutation-testing.md](docs/mutation-testing.md) shows how to find kernel behaviour these
|
||||
black-box suites miss.
|
||||
|
||||
**UI scenarios** drive the real app headless: start a tool, click a plane or a sketch point,
|
||||
press keys, then assert on the result.
|
||||
@@ -191,17 +189,17 @@ the same commit; otherwise it runs the full suite.
|
||||
**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 twelve minutes each), which run in full validation
|
||||
Pull requests skip a few heavy tests (one to four 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 but the slowest, `SLOW_SEEDS` in `ci.yml`),
|
||||
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all seeds),
|
||||
`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`, `tspline::tests::hostile_tsplines_never_panic`, and the `boolean_props` property tests
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, 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,10 +283,6 @@ 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, which runs the full CI (build, tests, goldens, Clippy); every push
|
||||
after that runs the full set again.
|
||||
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).
|
||||
|
||||
@@ -363,20 +357,6 @@ 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
|
||||
|
||||
Generated
+17
-17
@@ -4078,7 +4078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4109,11 +4109,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -4124,7 +4124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4133,7 +4133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4150,7 +4150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4167,7 +4167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4176,7 +4176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4193,7 +4193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4213,7 +4213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4233,7 +4233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4242,7 +4242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4256,7 +4256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4267,11 +4267,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4296,7 +4296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5826,7 +5826,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
+1
-10
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.2"
|
||||
version = "0.16.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -123,9 +123,6 @@ 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
|
||||
@@ -145,9 +142,3 @@ matext4cgmath = { path = "vendor/matext4cgmath" }
|
||||
|
||||
# Keep OpenGL pipelines, resolve textures and CAD callbacks at the same safe sample count.
|
||||
egui-wgpu = { path = "vendor/egui-wgpu" }
|
||||
|
||||
# Mutation testing (cargo-mutants, docs/mutation-testing.md): the ci profile with incremental
|
||||
# builds, since every mutant rebuilds the crates above the one it changes.
|
||||
[profile.mutants]
|
||||
inherits = "ci"
|
||||
incremental = true
|
||||
|
||||
@@ -311,7 +311,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
SolidCraft uses a familiar parametric CAD workflow: constrained
|
||||
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.
|
||||
|
||||
|
||||
+1
-7
@@ -14,10 +14,6 @@ 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
|
||||
@@ -59,7 +55,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| 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 | — |
|
||||
| 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 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 in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
@@ -349,8 +345,6 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 most parametric CAD tools do); M0 turned them the other way",
|
||||
"partial revolves turn toward the sketch normal since ac43365 (as Fusion's 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"),
|
||||
];
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
//! The reference tests (tests/oracle, docs/oracle.md) as a `cargo test`, so tools that drive
|
||||
//! `cargo test` (cargo-mutants) can use them: every case with expected values and no
|
||||
//! `known_failure` mark must pass, as `cargo xtask oracle` requires.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[test]
|
||||
#[ignore = "run by cargo-mutants (.cargo/mutants.toml); CI replays the oracle through cargo xtask oracle"]
|
||||
fn oracle_suite() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let dir = root.join("tests/oracle");
|
||||
let mut cases: Vec<String> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.join("recipe.json").is_file())
|
||||
.map(|p| format!("tests/oracle/{}", p.file_name().unwrap().to_string_lossy()))
|
||||
.collect();
|
||||
cases.sort();
|
||||
assert!(cases.len() >= 50, "oracle cases in {}", dir.display());
|
||||
// One process per core, cases dealt round-robin so the slow ones spread out.
|
||||
let n = std::thread::available_parallelism().map_or(4, |n| n.get()).min(cases.len());
|
||||
let children: Vec<_> = (0..n)
|
||||
.map(|i| {
|
||||
let chunk: Vec<&String> = cases.iter().skip(i).step_by(n).collect();
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.current_dir(&root)
|
||||
.arg("oracle")
|
||||
.arg("--json")
|
||||
.args(chunk)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
for child in children {
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let part: Vec<Value> =
|
||||
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("oracle JSON: {e}\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
results.extend(part);
|
||||
}
|
||||
assert_eq!(results.len(), cases.len());
|
||||
let mut failed = Vec::new();
|
||||
for r in &results {
|
||||
let case = r["case"].as_str().unwrap_or("");
|
||||
let want: Value =
|
||||
std::fs::read_to_string(dir.join(case).join("expected.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
|
||||
if want.is_null() || want["known_failure"].is_string() || r["pass"].as_bool().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let checks: Vec<String> = r["checks"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|c| !c["ok"].as_bool().unwrap_or(false))
|
||||
.map(|c| format!("{} got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"]))
|
||||
.collect();
|
||||
failed.push(format!("{case}: {} {}", r["error"].as_str().unwrap_or(""), checks.join("; ")));
|
||||
}
|
||||
assert!(failed.is_empty(), "oracle cases failed:\n{}", failed.join("\n"));
|
||||
}
|
||||
@@ -179,8 +179,7 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
}
|
||||
}
|
||||
let all: Vec<&str> = all.iter().map(String::as_str).collect();
|
||||
// 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();
|
||||
let future = file_parent.clone().add_filter(label, &all).pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
pick_program: Some(Box::new(move || {
|
||||
|
||||
@@ -362,31 +362,25 @@ 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);
|
||||
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();
|
||||
}
|
||||
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}");
|
||||
}
|
||||
Event::Buttons(_) => {}
|
||||
});
|
||||
match started {
|
||||
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
|
||||
Err(error) => log::info!("3D mouse: no driver client: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,10 +619,7 @@ mod tests {
|
||||
wheel: WheelGuard::default(),
|
||||
started: 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);
|
||||
mouse.tick_at(&mut app, &Context::default(), 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);
|
||||
|
||||
@@ -89,15 +89,6 @@ 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
-13
@@ -113,6 +113,7 @@
|
||||
"Follow the camera (Look At sketches)": "Sledovat kameru (náčrty natočené k pohledu)",
|
||||
"Font": "Písmo",
|
||||
"Full Length": "Celá délka",
|
||||
"Gap": "Mezera",
|
||||
"Grid": "Mřížka",
|
||||
"Height": "Výška",
|
||||
"Hidden edges": "Skryté hrany",
|
||||
@@ -687,15 +688,6 @@
|
||||
"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)",
|
||||
@@ -1039,7 +1031,6 @@
|
||||
"ui.construct": "Konstrukce",
|
||||
"ui.construction": "Konstrukční",
|
||||
"ui.context_menu": "Místní nabídka",
|
||||
"ui.continuity_gap": "Mezera",
|
||||
"ui.copy": "Kopírovat",
|
||||
"ui.corner": "Roh",
|
||||
"ui.corners": "Rohy",
|
||||
@@ -1346,8 +1337,6 @@
|
||||
"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",
|
||||
@@ -1358,7 +1347,6 @@
|
||||
"ui.send": "Odeslat",
|
||||
"ui.send_to_slicer": "Odeslat do sliceru",
|
||||
"ui.sent_to_slicer": "Odesláno do {slicer}",
|
||||
"ui.sheet_gap": "Mezera",
|
||||
"ui.shell": "Skořepina",
|
||||
"ui.show": "Zobrazit",
|
||||
"ui.showAll": "Zobrazit vše",
|
||||
|
||||
+1
-20
@@ -43,6 +43,7 @@
|
||||
"Faces": "Flächen",
|
||||
"Find in Browser": "Im Browser suchen",
|
||||
"Flat": "Flach",
|
||||
"Gap": "Spalt",
|
||||
"Hidden edges": "Verdeckte Kanten",
|
||||
"Holes": "Löcher",
|
||||
"Holes (edges on them)": "Löcher (ihre Kanten)",
|
||||
@@ -425,10 +426,8 @@
|
||||
"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",
|
||||
@@ -449,12 +448,7 @@
|
||||
"coupling.rack": "Zahnstange",
|
||||
"coupling.radius_1": "Radius 1",
|
||||
"coupling.radius_2": "Radius 2",
|
||||
"dialog.bodies_count": "{count} Körper",
|
||||
"dialog.clearance_invalid": "der Abstand muss eine Länge von 0 oder mehr sein",
|
||||
"dialog.compute": "Berechnen",
|
||||
"dialog.compute_again": "Erneut berechnen",
|
||||
"dialog.computing": "Wird berechnet… {seconds} s",
|
||||
"dialog.could_not_be_checked": "nicht prüfbar",
|
||||
"dialog.custom_axis": "↖ Eigene Achse",
|
||||
"dialog.edit_title": "{title} bearbeiten",
|
||||
"dialog.select": "↖ Auswählen",
|
||||
@@ -758,8 +752,6 @@
|
||||
"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",
|
||||
@@ -777,7 +769,6 @@
|
||||
"ui.base_point": "Basispunkt",
|
||||
"ui.blocked": "Verdeckt",
|
||||
"ui.bodies": "Körper",
|
||||
"ui.bodies_all_if_none": "Körper (alle, wenn keine)",
|
||||
"ui.box": "Block",
|
||||
"ui.break": "Unterbrechen",
|
||||
"ui.calibrate": "Kalibrieren",
|
||||
@@ -828,7 +819,6 @@
|
||||
"ui.continuity_g0": "G0 Knick",
|
||||
"ui.continuity_g1": "G1 Tangente",
|
||||
"ui.continuity_g2": "G2 Krümmung",
|
||||
"ui.continuity_gap": "Lücke",
|
||||
"ui.copy": "Kopieren",
|
||||
"ui.corner": "Ecke",
|
||||
"ui.corners": "Ecken",
|
||||
@@ -914,11 +904,6 @@
|
||||
"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",
|
||||
@@ -943,7 +928,6 @@
|
||||
"ui.heading_home": "START",
|
||||
"ui.heading_insert": "EINFÜGEN",
|
||||
"ui.heading_inspect": "PRÜFEN",
|
||||
"ui.heading_interference": "EINGRIFF",
|
||||
"ui.heading_loft": "LOFT",
|
||||
"ui.heading_measure": "Messen",
|
||||
"ui.heading_mesh": "MESH",
|
||||
@@ -1127,8 +1111,6 @@
|
||||
"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",
|
||||
@@ -1139,7 +1121,6 @@
|
||||
"ui.send": "Senden",
|
||||
"ui.send_to_slicer": "An Slicer senden",
|
||||
"ui.sent_to_slicer": "An {slicer} gesendet",
|
||||
"ui.sheet_gap": "Spalt",
|
||||
"ui.shell": "Hülle",
|
||||
"ui.show": "Einblenden",
|
||||
"ui.showAll": "Alle einblenden",
|
||||
|
||||
+3
-36
@@ -122,6 +122,7 @@
|
||||
"Follow the camera (Look At sketches)": "Follow the camera (Look At sketches)",
|
||||
"Font": "Font",
|
||||
"Full Length": "Full Length",
|
||||
"Gap": "Gap",
|
||||
"Grid": "Grid",
|
||||
"Height": "Height",
|
||||
"Hidden edges": "Hidden edges",
|
||||
@@ -350,7 +351,6 @@
|
||||
"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",
|
||||
@@ -633,10 +633,8 @@
|
||||
"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",
|
||||
@@ -658,12 +656,7 @@
|
||||
"coupling.rack": "Rack",
|
||||
"coupling.radius_1": "Radius 1",
|
||||
"coupling.radius_2": "Radius 2",
|
||||
"dialog.bodies_count": "{count} bodies",
|
||||
"dialog.clearance_invalid": "the clearance must be a length of 0 or more",
|
||||
"dialog.compute": "Compute",
|
||||
"dialog.compute_again": "Compute again",
|
||||
"dialog.computing": "Computing… {seconds} s",
|
||||
"dialog.could_not_be_checked": "could not be checked",
|
||||
"dialog.custom_axis": "↖ Custom Axis",
|
||||
"dialog.edit_title": "EDIT {title}",
|
||||
"dialog.select": "↖ Select",
|
||||
@@ -719,15 +712,6 @@
|
||||
"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)",
|
||||
@@ -869,7 +853,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, PDFs, text and CAD files",
|
||||
"ui.agent_chat_attachable": "Pictures, 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.",
|
||||
@@ -901,6 +885,7 @@
|
||||
"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",
|
||||
@@ -1047,8 +1032,6 @@
|
||||
"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",
|
||||
@@ -1066,7 +1049,6 @@
|
||||
"ui.base_point": "Base point",
|
||||
"ui.blocked": "Blocked",
|
||||
"ui.bodies": "Bodies",
|
||||
"ui.bodies_all_if_none": "Bodies (all if none)",
|
||||
"ui.box": "Box",
|
||||
"ui.break": "Break",
|
||||
"ui.calibrate": "Calibrate",
|
||||
@@ -1121,7 +1103,6 @@
|
||||
"ui.continuity_g0": "G0 crease",
|
||||
"ui.continuity_g1": "G1 tangent",
|
||||
"ui.continuity_g2": "G2 curvature",
|
||||
"ui.continuity_gap": "Gap",
|
||||
"ui.copy": "Copy",
|
||||
"ui.corner": "Corner",
|
||||
"ui.corners": "Corners",
|
||||
@@ -1209,11 +1190,6 @@
|
||||
"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",
|
||||
@@ -1242,7 +1218,6 @@
|
||||
"ui.heading_home": "HOME",
|
||||
"ui.heading_insert": "INSERT",
|
||||
"ui.heading_inspect": "INSPECT",
|
||||
"ui.heading_interference": "INTERFERENCE",
|
||||
"ui.heading_loft": "LOFT",
|
||||
"ui.heading_make": "MAKE",
|
||||
"ui.heading_measure": "MEASURE",
|
||||
@@ -1418,11 +1393,6 @@
|
||||
"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",
|
||||
@@ -1446,8 +1416,6 @@
|
||||
"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",
|
||||
@@ -1459,7 +1427,6 @@
|
||||
"ui.send": "Send",
|
||||
"ui.send_to_slicer": "Send to slicer",
|
||||
"ui.sent_to_slicer": "Sent to {slicer}",
|
||||
"ui.sheet_gap": "Gap",
|
||||
"ui.shell": "Shell",
|
||||
"ui.show": "Show",
|
||||
"ui.showAll": "Show All",
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
"Follow the camera (Look At sketches)": "Seguir la cámara (orientar hacia los bocetos)",
|
||||
"Font": "Fuente",
|
||||
"Full Length": "Longitud total",
|
||||
"Gap": "Separación",
|
||||
"Grid": "Cuadrícula",
|
||||
"Height": "Altura",
|
||||
"Hidden edges": "Aristas ocultas",
|
||||
@@ -1061,7 +1062,6 @@
|
||||
"ui.continuity_g0": "G0 arista",
|
||||
"ui.continuity_g1": "G1 tangencia",
|
||||
"ui.continuity_g2": "G2 curvatura",
|
||||
"ui.continuity_gap": "Separación",
|
||||
"ui.copy": "Copiar",
|
||||
"ui.corner": "Esquina",
|
||||
"ui.corners": "Esquinas",
|
||||
@@ -1372,8 +1372,6 @@
|
||||
"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",
|
||||
@@ -1385,7 +1383,6 @@
|
||||
"ui.send": "Enviar",
|
||||
"ui.send_to_slicer": "Enviar al laminador",
|
||||
"ui.sent_to_slicer": "Enviado a {slicer}",
|
||||
"ui.sheet_gap": "Separación",
|
||||
"ui.shell": "Vaciado",
|
||||
"ui.show": "Mostrar",
|
||||
"ui.showAll": "Mostrar todo",
|
||||
|
||||
+1
-20
@@ -120,6 +120,7 @@
|
||||
"Follow the camera (Look At sketches)": "Suivre la caméra (vue normale aux esquisses)",
|
||||
"Font": "Police",
|
||||
"Full Length": "Longueur totale",
|
||||
"Gap": "Jeu",
|
||||
"Grid": "Grille",
|
||||
"Height": "Hauteur",
|
||||
"Hidden edges": "Arêtes cachées",
|
||||
@@ -628,10 +629,8 @@
|
||||
"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",
|
||||
@@ -653,12 +652,7 @@
|
||||
"coupling.rack": "Crémaillère",
|
||||
"coupling.radius_1": "Rayon 1",
|
||||
"coupling.radius_2": "Rayon 2",
|
||||
"dialog.bodies_count": "{count} corps",
|
||||
"dialog.clearance_invalid": "le jeu doit être une longueur positive ou nulle",
|
||||
"dialog.compute": "Calculer",
|
||||
"dialog.compute_again": "Recalculer",
|
||||
"dialog.computing": "Calcul… {seconds} s",
|
||||
"dialog.could_not_be_checked": "non vérifiable",
|
||||
"dialog.custom_axis": "↖ Axe personnalisé",
|
||||
"dialog.edit_title": "MODIFIER {title}",
|
||||
"dialog.select": "↖ Sélectionner",
|
||||
@@ -997,8 +991,6 @@
|
||||
"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",
|
||||
@@ -1016,7 +1008,6 @@
|
||||
"ui.base_point": "Point de base",
|
||||
"ui.blocked": "Inaccessible",
|
||||
"ui.bodies": "Corps",
|
||||
"ui.bodies_all_if_none": "Corps (tous si aucun)",
|
||||
"ui.box": "Boîte",
|
||||
"ui.break": "Fractionner",
|
||||
"ui.calibrate": "Étalonner",
|
||||
@@ -1071,7 +1062,6 @@
|
||||
"ui.continuity_g0": "G0 angle vif",
|
||||
"ui.continuity_g1": "G1 tangence",
|
||||
"ui.continuity_g2": "G2 courbure",
|
||||
"ui.continuity_gap": "Écart",
|
||||
"ui.copy": "Copier",
|
||||
"ui.corner": "Angle",
|
||||
"ui.corners": "Angles",
|
||||
@@ -1159,11 +1149,6 @@
|
||||
"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",
|
||||
@@ -1192,7 +1177,6 @@
|
||||
"ui.heading_home": "ACCUEIL",
|
||||
"ui.heading_insert": "INSÉRER",
|
||||
"ui.heading_inspect": "INSPECTER",
|
||||
"ui.heading_interference": "INTERFÉRENCE",
|
||||
"ui.heading_loft": "LISSAGE",
|
||||
"ui.heading_make": "FABRIQUER",
|
||||
"ui.heading_measure": "MESURER",
|
||||
@@ -1388,8 +1372,6 @@
|
||||
"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",
|
||||
@@ -1401,7 +1383,6 @@
|
||||
"ui.send": "Envoyer",
|
||||
"ui.send_to_slicer": "Envoyer au slicer",
|
||||
"ui.sent_to_slicer": "Envoyé à {slicer}",
|
||||
"ui.sheet_gap": "Jeu",
|
||||
"ui.shell": "Coque",
|
||||
"ui.show": "Afficher",
|
||||
"ui.showAll": "Tout afficher",
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
"Follow the camera (Look At sketches)": "Seguir a cámara (Mirar cara aos bosquexos)",
|
||||
"Font": "Tipo de letra",
|
||||
"Full Length": "Lonxitude completa",
|
||||
"Gap": "Separación",
|
||||
"Grid": "Grella",
|
||||
"Height": "Altura",
|
||||
"Hidden edges": "Arestas ocultas",
|
||||
@@ -1030,7 +1031,6 @@
|
||||
"ui.construct": "Construír",
|
||||
"ui.construction": "Construción",
|
||||
"ui.context_menu": "Menú contextual",
|
||||
"ui.continuity_gap": "Separación",
|
||||
"ui.copy": "Copiar",
|
||||
"ui.corner": "Esquina",
|
||||
"ui.corners": "Esquinas",
|
||||
@@ -1337,8 +1337,6 @@
|
||||
"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",
|
||||
@@ -1349,7 +1347,6 @@
|
||||
"ui.send": "Enviar",
|
||||
"ui.send_to_slicer": "Enviar ao laminador",
|
||||
"ui.sent_to_slicer": "Enviado a {slicer}",
|
||||
"ui.sheet_gap": "Separación",
|
||||
"ui.shell": "Baleirado",
|
||||
"ui.show": "Mostrar",
|
||||
"ui.showAll": "Mostrar todo",
|
||||
|
||||
+1
-19
@@ -119,6 +119,7 @@
|
||||
"Follow the camera (Look At sketches)": "Segui la camera (Vista normale agli schizzi)",
|
||||
"Font": "Carattere",
|
||||
"Full Length": "Lunghezza totale",
|
||||
"Gap": "Gioco",
|
||||
"Grid": "Griglia",
|
||||
"Height": "Altezza",
|
||||
"Hidden edges": "Spigoli nascosti",
|
||||
@@ -345,7 +346,6 @@
|
||||
"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",
|
||||
@@ -704,15 +704,6 @@
|
||||
"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)",
|
||||
@@ -1064,7 +1055,6 @@
|
||||
"ui.construct": "Costruisci",
|
||||
"ui.construction": "Costruzione",
|
||||
"ui.context_menu": "Menu contestuale",
|
||||
"ui.continuity_gap": "Gioco",
|
||||
"ui.copy": "Copia",
|
||||
"ui.corner": "Angolo",
|
||||
"ui.corners": "Angoli",
|
||||
@@ -1352,11 +1342,6 @@
|
||||
"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",
|
||||
@@ -1378,8 +1363,6 @@
|
||||
"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",
|
||||
@@ -1391,7 +1374,6 @@
|
||||
"ui.send": "Invia",
|
||||
"ui.send_to_slicer": "Invia allo slicer",
|
||||
"ui.sent_to_slicer": "Inviato a {slicer}",
|
||||
"ui.sheet_gap": "Gioco",
|
||||
"ui.shell": "Svuota",
|
||||
"ui.show": "Mostra",
|
||||
"ui.showAll": "Mostra tutto",
|
||||
|
||||
+3
-36
@@ -122,6 +122,7 @@
|
||||
"Follow the camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
|
||||
"Font": "フォント",
|
||||
"Full Length": "全長",
|
||||
"Gap": "ギャップ",
|
||||
"Grid": "グリッド",
|
||||
"Height": "高さ",
|
||||
"Hidden edges": "隠線",
|
||||
@@ -350,7 +351,6 @@
|
||||
"cmd.component_move_sketches": "スケッチをコンポーネントに移動",
|
||||
"cmd.component_paste_new": "新規として貼り付け",
|
||||
"cmd.component_properties": "コンポーネントの物理的プロパティ",
|
||||
"cmd.component_relink": "参照元を再リンク",
|
||||
"cmd.component_rename": "コンポーネントの名前を変更",
|
||||
"cmd.component_revert_position": "位置を元に戻す",
|
||||
"cmd.component_unlock": "内容のロックを解除",
|
||||
@@ -633,10 +633,8 @@
|
||||
"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": "名前付きビューの名前を変更",
|
||||
@@ -658,12 +656,7 @@
|
||||
"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": "↖ 選択",
|
||||
@@ -719,15 +712,6 @@
|
||||
"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": "秒 (空欄: ステップごと)",
|
||||
@@ -869,7 +853,7 @@
|
||||
"ui.agent_chat_approval": "エージェントにこの操作を許可しますか?",
|
||||
"ui.agent_chat_attach_view": "ビューを添付",
|
||||
"ui.agent_chat_attach_view_tip": "現在のビューの画像をメッセージと一緒に送ります",
|
||||
"ui.agent_chat_attachable": "画像・PDF・テキスト・CAD ファイル",
|
||||
"ui.agent_chat_attachable": "画像・テキスト・CAD ファイル",
|
||||
"ui.agent_chat_auth_agent": "{name} でサインイン",
|
||||
"ui.agent_chat_auth_env": "環境変数 {vars} を設定して SolidCraft を再起動してください。",
|
||||
"ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。SolidCraft がパスワードやキーを見ることはありません。",
|
||||
@@ -901,6 +885,7 @@
|
||||
"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}: 画像ではありません",
|
||||
@@ -1047,8 +1032,6 @@
|
||||
"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": "角度の小数点以下の桁数",
|
||||
@@ -1066,7 +1049,6 @@
|
||||
"ui.base_point": "基点",
|
||||
"ui.blocked": "到達不可",
|
||||
"ui.bodies": "ボディ",
|
||||
"ui.bodies_all_if_none": "ボディ(未選択ならすべて)",
|
||||
"ui.box": "直方体",
|
||||
"ui.break": "分割",
|
||||
"ui.calibrate": "校正",
|
||||
@@ -1121,7 +1103,6 @@
|
||||
"ui.continuity_g0": "G0 折れ",
|
||||
"ui.continuity_g1": "G1 接線",
|
||||
"ui.continuity_g2": "G2 曲率",
|
||||
"ui.continuity_gap": "隙間",
|
||||
"ui.copy": "コピー",
|
||||
"ui.corner": "コーナー",
|
||||
"ui.corners": "コーナー",
|
||||
@@ -1209,11 +1190,6 @@
|
||||
"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": "地面の影",
|
||||
@@ -1242,7 +1218,6 @@
|
||||
"ui.heading_home": "ホーム",
|
||||
"ui.heading_insert": "挿入",
|
||||
"ui.heading_inspect": "検査",
|
||||
"ui.heading_interference": "干渉",
|
||||
"ui.heading_loft": "ロフト",
|
||||
"ui.heading_make": "製造",
|
||||
"ui.heading_measure": "計測",
|
||||
@@ -1418,11 +1393,6 @@
|
||||
"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": "レンダリング",
|
||||
@@ -1446,8 +1416,6 @@
|
||||
"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": "断面解析",
|
||||
@@ -1459,7 +1427,6 @@
|
||||
"ui.send": "送信",
|
||||
"ui.send_to_slicer": "スライサーに送る",
|
||||
"ui.sent_to_slicer": "{slicer} に送信しました",
|
||||
"ui.sheet_gap": "ギャップ",
|
||||
"ui.shell": "シェル",
|
||||
"ui.show": "表示",
|
||||
"ui.showAll": "すべて表示",
|
||||
|
||||
+1
-20
@@ -43,6 +43,7 @@
|
||||
"Faces": "면",
|
||||
"Find in Browser": "브라우저에서 찾기",
|
||||
"Flat": "플랫",
|
||||
"Gap": "틈",
|
||||
"Hidden edges": "숨겨진 모서리",
|
||||
"Holes": "구멍",
|
||||
"Holes (edges on them)": "구멍 (구멍의 모서리)",
|
||||
@@ -425,10 +426,8 @@
|
||||
"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": "이름 있는 보기 이름 변경",
|
||||
@@ -449,12 +448,7 @@
|
||||
"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": "↖ 선택",
|
||||
@@ -758,8 +752,6 @@
|
||||
"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": "각도 소수점 자릿수",
|
||||
@@ -777,7 +769,6 @@
|
||||
"ui.base_point": "기준점",
|
||||
"ui.blocked": "막힘",
|
||||
"ui.bodies": "바디",
|
||||
"ui.bodies_all_if_none": "바디 (없으면 전체)",
|
||||
"ui.box": "박스",
|
||||
"ui.break": "끊기",
|
||||
"ui.calibrate": "캘리브레이션",
|
||||
@@ -828,7 +819,6 @@
|
||||
"ui.continuity_g0": "G0 꺾임",
|
||||
"ui.continuity_g1": "G1 접선",
|
||||
"ui.continuity_g2": "G2 곡률",
|
||||
"ui.continuity_gap": "틈",
|
||||
"ui.copy": "복사",
|
||||
"ui.corner": "코너",
|
||||
"ui.corners": "코너",
|
||||
@@ -914,11 +904,6 @@
|
||||
"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": "박스",
|
||||
@@ -943,7 +928,6 @@
|
||||
"ui.heading_home": "홈",
|
||||
"ui.heading_insert": "삽입",
|
||||
"ui.heading_inspect": "검사",
|
||||
"ui.heading_interference": "간섭",
|
||||
"ui.heading_loft": "로프트",
|
||||
"ui.heading_measure": "측정",
|
||||
"ui.heading_mesh": "메시",
|
||||
@@ -1125,8 +1109,6 @@
|
||||
"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": "선택",
|
||||
@@ -1136,7 +1118,6 @@
|
||||
"ui.send": "보내기",
|
||||
"ui.send_to_slicer": "슬라이서로 보내기",
|
||||
"ui.sent_to_slicer": "{slicer}(으)로 보냈습니다",
|
||||
"ui.sheet_gap": "틈",
|
||||
"ui.shell": "쉘",
|
||||
"ui.show": "표시",
|
||||
"ui.showAll": "모두 표시",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -122,6 +122,7 @@
|
||||
"Follow the camera (Look At sketches)": "Podążaj za kamerą (Patrz na szkice)",
|
||||
"Font": "Czcionka",
|
||||
"Full Length": "Pełna długość",
|
||||
"Gap": "Szczelina",
|
||||
"Grid": "Siatka",
|
||||
"Height": "Wysokość",
|
||||
"Hidden edges": "Krawędzie niewidoczne",
|
||||
@@ -1070,7 +1071,6 @@
|
||||
"ui.continuity_g0": "G0 załamanie",
|
||||
"ui.continuity_g1": "G1 styczność",
|
||||
"ui.continuity_g2": "G2 krzywizna",
|
||||
"ui.continuity_gap": "Szczelina",
|
||||
"ui.copy": "Kopiuj",
|
||||
"ui.corner": "Naroże",
|
||||
"ui.corners": "Naroża",
|
||||
@@ -1384,8 +1384,6 @@
|
||||
"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",
|
||||
@@ -1397,7 +1395,6 @@
|
||||
"ui.send": "Wyślij",
|
||||
"ui.send_to_slicer": "Wyślij do slicera",
|
||||
"ui.sent_to_slicer": "Wysłano do {slicer}",
|
||||
"ui.sheet_gap": "Szczelina",
|
||||
"ui.shell": "Skorupa",
|
||||
"ui.show": "Pokaż",
|
||||
"ui.showAll": "Pokaż wszystko",
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
"Follow the camera (Look At sketches)": "Seguir a câmera (Olhar para os esboços)",
|
||||
"Font": "Fonte",
|
||||
"Full Length": "Comprimento total",
|
||||
"Gap": "Folga",
|
||||
"Grid": "Grade",
|
||||
"Height": "Altura",
|
||||
"Hidden edges": "Arestas ocultas",
|
||||
@@ -1010,7 +1011,6 @@
|
||||
"ui.construct": "Construir",
|
||||
"ui.construction": "Construção",
|
||||
"ui.context_menu": "Menu de contexto",
|
||||
"ui.continuity_gap": "Folga",
|
||||
"ui.copy": "Copiar",
|
||||
"ui.corner": "Canto",
|
||||
"ui.corners": "Cantos",
|
||||
@@ -1317,8 +1317,6 @@
|
||||
"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",
|
||||
@@ -1329,7 +1327,6 @@
|
||||
"ui.send": "Enviar",
|
||||
"ui.send_to_slicer": "Enviar ao fatiador",
|
||||
"ui.sent_to_slicer": "Enviado para {slicer}",
|
||||
"ui.sheet_gap": "Folga",
|
||||
"ui.shell": "Casca",
|
||||
"ui.show": "Mostrar",
|
||||
"ui.showAll": "Mostrar tudo",
|
||||
|
||||
+1
-13
@@ -102,6 +102,7 @@
|
||||
"Follow the camera (Look At sketches)": "Следовать за камерой (эскизы «Взгляд на»)",
|
||||
"Font": "Шрифт",
|
||||
"Full Length": "Полная длина",
|
||||
"Gap": "Зазор",
|
||||
"Grid": "Сетка",
|
||||
"Height": "Высота",
|
||||
"Home view": "Главный вид",
|
||||
@@ -568,10 +569,8 @@
|
||||
"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": "Переименовать именованный вид",
|
||||
@@ -752,8 +751,6 @@
|
||||
"ui.agent_cursor": "Курсор агента",
|
||||
"ui.agent_cursor_speed": "Скорость курсора агента",
|
||||
"ui.all": "Все",
|
||||
"ui.ambient_occlusion": "Окружающее затенение",
|
||||
"ui.ambient_occlusion_help": "Мягкое затенение в местах соприкосновения непрозрачных поверхностей. Требуется видовой экран GPU.",
|
||||
"ui.analysis": "Анализ",
|
||||
"ui.angle": "Угол",
|
||||
"ui.angle_decimals": "Знаков после запятой (углы)",
|
||||
@@ -821,7 +818,6 @@
|
||||
"ui.construct": "Построение",
|
||||
"ui.construction": "Вспомогательная",
|
||||
"ui.context_menu": "Контекстное меню",
|
||||
"ui.continuity_gap": "Зазор",
|
||||
"ui.copy": "Копировать",
|
||||
"ui.corner": "Угол",
|
||||
"ui.corners": "Углы",
|
||||
@@ -903,11 +899,6 @@
|
||||
"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": "НАДСТРОЙКИ",
|
||||
@@ -1125,8 +1116,6 @@
|
||||
"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": "Выбрать",
|
||||
@@ -1137,7 +1126,6 @@
|
||||
"ui.send": "Отправить",
|
||||
"ui.send_to_slicer": "Отправить в слайсер",
|
||||
"ui.sent_to_slicer": "Отправлено в {slicer}",
|
||||
"ui.sheet_gap": "Зазор",
|
||||
"ui.shell": "Оболочка",
|
||||
"ui.show": "Показать",
|
||||
"ui.showAll": "Показать всё",
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"Follow the camera (Look At sketches)": "Kamera takibi (eskizlerde Bak)",
|
||||
"Font": "Yazı tipi",
|
||||
"Full Length": "Tam uzunluk",
|
||||
"Gap": "Boşluk",
|
||||
"Grid": "Izgara",
|
||||
"Height": "Yükseklik",
|
||||
"Holes": "Delikler",
|
||||
@@ -959,7 +960,6 @@
|
||||
"ui.construct": "Yapı",
|
||||
"ui.construction": "Yapı çizgisi",
|
||||
"ui.context_menu": "Bağlam menüsü",
|
||||
"ui.continuity_gap": "Boşluk",
|
||||
"ui.copy": "Kopyala",
|
||||
"ui.corner": "Köşe",
|
||||
"ui.corners": "Köşeler",
|
||||
@@ -1264,8 +1264,6 @@
|
||||
"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ç",
|
||||
@@ -1276,7 +1274,6 @@
|
||||
"ui.send": "Gönder",
|
||||
"ui.send_to_slicer": "Dilimleyiciye gönder",
|
||||
"ui.sent_to_slicer": "{slicer} uygulamasına gönderildi",
|
||||
"ui.sheet_gap": "Boşluk",
|
||||
"ui.shell": "Kabuk",
|
||||
"ui.show": "Göster",
|
||||
"ui.showAll": "Tümünü göster",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"Follow the camera (Look At sketches)": "Стежити за камерою (ескізи наведення)",
|
||||
"Font": "Шрифт",
|
||||
"Full Length": "Повна довжина",
|
||||
"Gap": "Проміжок",
|
||||
"Grid": "Сітка",
|
||||
"Height": "Висота",
|
||||
"Hidden edges": "Приховані ребра",
|
||||
@@ -1033,7 +1034,6 @@
|
||||
"ui.construct": "Побудувати",
|
||||
"ui.construction": "Конструкційна",
|
||||
"ui.context_menu": "Контекстне меню",
|
||||
"ui.continuity_gap": "Проміжок",
|
||||
"ui.copy": "Копіювати",
|
||||
"ui.corner": "Кут",
|
||||
"ui.corners": "Кути",
|
||||
@@ -1340,8 +1340,6 @@
|
||||
"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": "Вибрати",
|
||||
@@ -1352,7 +1350,6 @@
|
||||
"ui.send": "Надіслати",
|
||||
"ui.send_to_slicer": "Надіслати в слайсер",
|
||||
"ui.sent_to_slicer": "Надіслано в {slicer}",
|
||||
"ui.sheet_gap": "Проміжок",
|
||||
"ui.shell": "Оболонка",
|
||||
"ui.show": "Показати",
|
||||
"ui.showAll": "Показати все",
|
||||
|
||||
+3
-36
@@ -122,6 +122,7 @@
|
||||
"Follow the camera (Look At sketches)": "跟随相机(注视草图)",
|
||||
"Font": "字体",
|
||||
"Full Length": "全长",
|
||||
"Gap": "间隙",
|
||||
"Grid": "网格",
|
||||
"Height": "高度",
|
||||
"Hidden edges": "隐藏线",
|
||||
@@ -350,7 +351,6 @@
|
||||
"cmd.component_move_sketches": "将草图移至组件",
|
||||
"cmd.component_paste_new": "粘贴为新对象",
|
||||
"cmd.component_properties": "组件物理属性",
|
||||
"cmd.component_relink": "重新链接源文件",
|
||||
"cmd.component_rename": "重命名组件",
|
||||
"cmd.component_revert_position": "恢复位置",
|
||||
"cmd.component_unlock": "解锁内容",
|
||||
@@ -633,10 +633,8 @@
|
||||
"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": "重命名命名视图",
|
||||
@@ -658,12 +656,7 @@
|
||||
"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": "↖ 选择",
|
||||
@@ -719,15 +712,6 @@
|
||||
"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": "秒(留空:按步)",
|
||||
@@ -869,7 +853,7 @@
|
||||
"ui.agent_chat_approval": "允许智能体执行此操作吗?",
|
||||
"ui.agent_chat_attach_view": "附加视图",
|
||||
"ui.agent_chat_attach_view_tip": "随消息发送当前视图的图片",
|
||||
"ui.agent_chat_attachable": "图片、PDF、文本和 CAD 文件",
|
||||
"ui.agent_chat_attachable": "图片、文本和 CAD 文件",
|
||||
"ui.agent_chat_auth_agent": "使用 {name} 登录",
|
||||
"ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 SolidCraft。",
|
||||
"ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;SolidCraft 从不接触你的密码或密钥。",
|
||||
@@ -901,6 +885,7 @@
|
||||
"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}:不是图片",
|
||||
@@ -1047,8 +1032,6 @@
|
||||
"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": "角度小数位",
|
||||
@@ -1066,7 +1049,6 @@
|
||||
"ui.base_point": "基点",
|
||||
"ui.blocked": "被遮挡",
|
||||
"ui.bodies": "实体",
|
||||
"ui.bodies_all_if_none": "实体(未选择则全部)",
|
||||
"ui.box": "长方体",
|
||||
"ui.break": "打断",
|
||||
"ui.calibrate": "校准",
|
||||
@@ -1121,7 +1103,6 @@
|
||||
"ui.continuity_g0": "G0 折痕",
|
||||
"ui.continuity_g1": "G1 相切",
|
||||
"ui.continuity_g2": "G2 曲率",
|
||||
"ui.continuity_gap": "缝隙",
|
||||
"ui.copy": "复制",
|
||||
"ui.corner": "角",
|
||||
"ui.corners": "角点",
|
||||
@@ -1209,11 +1190,6 @@
|
||||
"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": "地面阴影",
|
||||
@@ -1242,7 +1218,6 @@
|
||||
"ui.heading_home": "主页",
|
||||
"ui.heading_insert": "插入",
|
||||
"ui.heading_inspect": "检查",
|
||||
"ui.heading_interference": "干涉",
|
||||
"ui.heading_loft": "放样",
|
||||
"ui.heading_make": "制作",
|
||||
"ui.heading_measure": "测量",
|
||||
@@ -1418,11 +1393,6 @@
|
||||
"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": "渲染",
|
||||
@@ -1446,8 +1416,6 @@
|
||||
"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": "截面分析",
|
||||
@@ -1459,7 +1427,6 @@
|
||||
"ui.send": "发送",
|
||||
"ui.send_to_slicer": "发送到切片软件",
|
||||
"ui.sent_to_slicer": "已发送到 {slicer}",
|
||||
"ui.sheet_gap": "间隙",
|
||||
"ui.shell": "抽壳",
|
||||
"ui.show": "显示",
|
||||
"ui.showAll": "显示全部",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT OR Apache-2.0</project_license>
|
||||
<name>SolidCraft</name>
|
||||
<summary>Independent parametric 3D CAD</summary>
|
||||
<summary>Parametric 3D CAD in the style of Fusion</summary>
|
||||
<developer id="io.github.bherbruck">
|
||||
<name>SolidCraft contributors</name>
|
||||
</developer>
|
||||
|
||||
@@ -10,7 +10,6 @@ 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>,
|
||||
@@ -29,7 +28,6 @@ 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_depth_2d;
|
||||
|
||||
fn clipped(p: vec3<f32>) -> bool {
|
||||
return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w;
|
||||
@@ -121,7 +119,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 ("from component" colouring), tinted toward the
|
||||
// The cut face in its body's colour (as Fusion's "from component"), 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);
|
||||
@@ -130,7 +128,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 = lit(i, occlusion(i));
|
||||
let c = shade(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) {
|
||||
@@ -140,59 +138,6 @@ 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.
|
||||
@fragment
|
||||
fn fs_ao_depth(i: TOut, @builtin(front_facing) front: bool) -> @builtin(frag_depth) f32 {
|
||||
if (clipped(i.wp) || length(i.n) < 0.5) { discard; }
|
||||
if (!front && length(u.clip.xyz) > 0.5) { return cap_depth(i.wp, i.pos.z); }
|
||||
return i.pos.z;
|
||||
}
|
||||
|
||||
// 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 = textureLoad(ao_depth, vec2<i32>(uv * vec2<f32>(size)), 0);
|
||||
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);
|
||||
@@ -285,10 +230,6 @@ 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);
|
||||
@@ -301,7 +242,7 @@ fn lit(i: TOut, visibility: f32) -> 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 - (0.42 + 0.25 * fill) * (1.0 - visibility);
|
||||
let k = 0.42 + 0.38 * diff + 0.25 * fill;
|
||||
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), i.c.a));
|
||||
}
|
||||
|
||||
|
||||
@@ -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).unit().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?;
|
||||
let axis = apply_vector(transform, t.axis).normalized().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 (Insert Canvas), kept in the design.
|
||||
//! Canvases: reference images placed on a plane (Fusion's 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};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 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 (the CONSTRUCT panel).
|
||||
//! built from them (Fusion's 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.
|
||||
@@ -285,9 +285,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 +367,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 +403,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().unit().ok_or_else(|| bad("degenerate plane"))?, point: pl.origin }, pl.origin))
|
||||
Ok((FaceSurface::Plane { normal: pl.normal(), point: pl.origin }, pl.origin))
|
||||
}
|
||||
_ => Err(bad("pick a face")),
|
||||
}
|
||||
|
||||
@@ -812,11 +812,11 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
line: Option<String>,
|
||||
},
|
||||
/// A construction axis (CONSTRUCT axes).
|
||||
/// A construction axis (Fusion's CONSTRUCT axes).
|
||||
ConstructionAxis {
|
||||
def: AxisDef,
|
||||
},
|
||||
/// A construction point (CONSTRUCT points).
|
||||
/// A construction point (Fusion's CONSTRUCT points).
|
||||
ConstructionPoint {
|
||||
def: PointDef,
|
||||
},
|
||||
@@ -2065,7 +2065,7 @@ impl FeatureKind {
|
||||
)
|
||||
}
|
||||
|
||||
/// Short type name (timeline tooltip, `inspect`).
|
||||
/// Fusion-like type name (timeline tooltip, `inspect`).
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
|
||||
@@ -3273,7 +3273,7 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Density of the default material (g/cm³), as the default.
|
||||
/// Density of the default material (g/cm³), as Fusion's default.
|
||||
#[path = "../../../assets/materials/library.rs"]
|
||||
mod materials;
|
||||
pub use materials::{DEFAULT_DENSITY, MATERIALS, material_color};
|
||||
|
||||
+12
-31
@@ -1,15 +1,12 @@
|
||||
//! 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 solidcraft_geom::tess::TessPlan;
|
||||
use solidcraft_geom::{Mesh, Plane, Region2, UnitVec3, Vec2, Vec3, regions_apart};
|
||||
use solidcraft_geom::{Mesh, Plane, Region2, Vec2, Vec3, regions_apart};
|
||||
use solidcraft_kernel::{self as kernel, Body, BoolOp};
|
||||
use solidcraft_sketch::{CurveKind, Profile, SolveReport, find_profiles};
|
||||
|
||||
@@ -31,8 +28,6 @@ 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]>,
|
||||
@@ -66,7 +61,6 @@ 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,
|
||||
@@ -89,10 +83,9 @@ 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`]).
|
||||
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()),
|
||||
}
|
||||
self.mesh
|
||||
.get_or_init(|| Arc::new(self.body.reference_display_mesh_angular(self.display_tol(), kernel::DISPLAY_ANGLE).unwrap_or_default()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The display plan of this body at the viewport's display quality
|
||||
@@ -152,10 +145,9 @@ impl ModelBody {
|
||||
Some((Arc::new(plan), Arc::new(mesh)))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The chord tolerance of [`ModelBody::mesh`] (mm): 1/1000 of the body's size, at least 1 µm.
|
||||
pub fn display_tol(&self) -> f64 {
|
||||
self.mesh_placement.as_ref().map(|placed| placed.tolerance).unwrap_or_else(|| (self.body.size() * 1e-3).max(1e-3))
|
||||
(self.body.size() * 1e-3).max(1e-3)
|
||||
}
|
||||
|
||||
/// Already computed display geometry; saving must not trigger cold tessellation.
|
||||
@@ -187,7 +179,7 @@ pub struct ThreadInfo {
|
||||
pub pitch: f64,
|
||||
pub internal: bool,
|
||||
pub axis_point: Vec3,
|
||||
pub axis: UnitVec3,
|
||||
pub axis: Vec3,
|
||||
/// Threaded part along the axis from `axis_point`.
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
@@ -373,10 +365,10 @@ 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)?)
|
||||
Ok(kernel::sweep_helix_mesh(&plane, §ion, base, k, p, (s1 - s0) / p, 48)?)
|
||||
}
|
||||
|
||||
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
|
||||
@@ -2398,7 +2390,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)
|
||||
@@ -4364,9 +4356,6 @@ 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
|
||||
@@ -5718,19 +5707,11 @@ 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 => match kernel::transform_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),
|
||||
},
|
||||
None => ModelBody::new(name, kernel::transform_matrix(&b.body, *m).unwrap_or_else(|_| b.body.clone()), b.feature),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5824,7 +5805,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).unit().unwrap_or(t.axis);
|
||||
t2.axis = crate::apply_vector(&frame, t.axis);
|
||||
out.threads.push(t2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 == ';' {
|
||||
// Some CAD tools separate function arguments with `;` in some locales.
|
||||
// Fusion separates function arguments with `;` in some locales.
|
||||
out.push((Tok::Op(','), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if "+-*/^(),%".contains(c) {
|
||||
|
||||
@@ -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); `flip` now means
|
||||
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `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.
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
//! 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).
|
||||
@@ -1,4 +0,0 @@
|
||||
//! A joint origin can sit on an edge of a planar face (`Snap::Edge`, saved as `"type": "edge"`):
|
||||
//! at the edge's middle or at a corner where it meets another edge, all three found again by name.
|
||||
//! Older designs need no transformation: they have no such origins. The last release refuses a
|
||||
//! design saved with this (the newer format number).
|
||||
@@ -1,5 +0,0 @@
|
||||
//! A face or circle joint origin given no `x` reference keeps the x its frame had when picked
|
||||
//! (`x` as a direction, component coordinates) and lays it onto the face as it is now, so a part
|
||||
//! on a face that tilts keeps its turn. Older designs need no transformation: an origin without
|
||||
//! `x` works its frame out from the normal alone, as before. The last release refuses a design
|
||||
//! saved with this (the newer format number).
|
||||
+3
-205
@@ -62,18 +62,6 @@ pub enum Snap {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
x: Option<XRef>,
|
||||
},
|
||||
/// A point on an edge of a planar face: the edge's middle, or the corner where it meets the
|
||||
/// edge `corner` names (`joints_edge.rs`). `edge` and `face` are the persistent names of the
|
||||
/// edge and the face (found first); `pick` is a point on the edge as it was picked.
|
||||
Edge {
|
||||
pick: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
edge: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
face: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
corner: Option<String>,
|
||||
},
|
||||
/// A point, z along `z` (default +Z).
|
||||
Point {
|
||||
point: Vec3,
|
||||
@@ -100,8 +88,7 @@ pub enum XRef {
|
||||
}
|
||||
|
||||
impl Snap {
|
||||
/// The persistent face and edge names the snap refers to (its face, its end's, its x reference's;
|
||||
/// an edge origin's edge, face and corner edge).
|
||||
/// The persistent face names the snap refers to (its face, its end's, its x reference's).
|
||||
pub fn face_names(&self) -> Vec<&String> {
|
||||
let mut out = Vec::new();
|
||||
if let Snap::FaceCenter { face, x, .. } | Snap::CircleCenter { face, x, .. } = self {
|
||||
@@ -113,9 +100,6 @@ impl Snap {
|
||||
if let Snap::CircleCenter { end: Some(end), .. } = self {
|
||||
out.push(end);
|
||||
}
|
||||
if let Snap::Edge { edge, face, corner, .. } = self {
|
||||
out.extend(edge.iter().chain(face.iter()).chain(corner.iter()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -136,33 +120,11 @@ impl Snap {
|
||||
out.push(face);
|
||||
}
|
||||
}
|
||||
Snap::Edge { edge, face, corner, .. } => out.extend(edge.iter_mut().chain(face.iter_mut()).chain(corner.iter_mut())),
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The direction a face or circle snap keeps as its frame's x (`XRef::Dir`), in the
|
||||
/// component's coordinates; `None` for a face reference or none.
|
||||
pub fn frame_x(&self) -> Option<Vec3> {
|
||||
match self {
|
||||
Snap::FaceCenter { x: Some(XRef::Dir(d)), .. } | Snap::CircleCenter { x: Some(XRef::Dir(d)), .. } => Some(*d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A face or circle snap given no x reference keeps `x` (the frame's x as it is now): each
|
||||
/// solve lays it onto the face, or square to the hole's axis, as the geometry is then, so a
|
||||
/// part on a face that tilts keeps its turn about z. Without it (designs made before) x
|
||||
/// follows from z alone, and turns through a right angle where z crosses 0.9 along the
|
||||
/// component's X. A snap with a reference keeps it.
|
||||
pub fn with_frame_x(mut self, x: Vec3) -> Snap {
|
||||
if let Snap::FaceCenter { x: slot @ None, .. } | Snap::CircleCenter { x: slot @ None, .. } = &mut self {
|
||||
*slot = Some(XRef::Dir(x));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether every point and direction in the snap is finite.
|
||||
pub fn is_finite(&self) -> bool {
|
||||
let x_ok = |x: &Option<XRef>| match x {
|
||||
@@ -174,7 +136,6 @@ impl Snap {
|
||||
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::Edge { pick, .. } => pick.is_finite(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,92 +221,6 @@ 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
|
||||
@@ -532,49 +407,6 @@ 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()
|
||||
@@ -687,7 +519,6 @@ pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Resul
|
||||
};
|
||||
frame(c, n, face::x_axis(doc, st, x.as_ref(), c)?).ok_or_else(bad)
|
||||
}
|
||||
Snap::Edge { .. } => edge::edge_frame(doc, st, component_of(doc, o.occurrence)?, &o.snap),
|
||||
Snap::CircleCenter { x, .. } => {
|
||||
let r = round_of(doc, st, o)?;
|
||||
frame(r.centre, r.z, face::x_axis(doc, st, x.as_ref(), r.centre)?).ok_or_else(bad)
|
||||
@@ -987,7 +818,7 @@ fn close_loops(tree: &Tree, closing: &[u64], asm: &Assembly, held: &Held, vals:
|
||||
if closing.len() > MAX_LOOPS {
|
||||
return (Values::new(), stuck);
|
||||
}
|
||||
let linked = set_values(asm);
|
||||
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
|
||||
let groups = loop_groups(tree, closing, asm, &linked);
|
||||
let spent = std::cell::Cell::new(0usize);
|
||||
for (group, around, touches) in &groups {
|
||||
@@ -1182,7 +1013,7 @@ fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &m
|
||||
if closing.len() > MAX_LOOPS {
|
||||
return;
|
||||
}
|
||||
let linked = set_values(asm);
|
||||
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
|
||||
let groups = loop_groups(tree, closing, asm, &linked);
|
||||
// The joints loops depend on: those around them, those whose values they read, and those a
|
||||
// motion link carries any of these values into.
|
||||
@@ -1595,14 +1426,6 @@ fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Soluti
|
||||
// Joint frames in component coordinates (errors leave the joint out).
|
||||
let mut frames: BTreeMap<u64, (Mat, Mat)> = BTreeMap::new();
|
||||
for j in &joints {
|
||||
// A joint whose expressions can't be worked out (a parameter gone) is left out rather
|
||||
// than placed by numbers that no longer say what it was defined by.
|
||||
if !j.exprs.is_empty()
|
||||
&& let Err(e) = expressed(doc, j)
|
||||
{
|
||||
sol.errors.push((j.id, j.name.clone(), e.to_string()));
|
||||
continue;
|
||||
}
|
||||
match (resolve_origin(doc, st, &j.a), resolve_origin(doc, st, &j.b)) {
|
||||
(Ok(fa), Ok(fb)) => {
|
||||
frames.insert(j.id, (fa, fb));
|
||||
@@ -1725,10 +1548,6 @@ impl Document {
|
||||
#[path = "joints_cam.rs"]
|
||||
pub mod cam;
|
||||
|
||||
#[path = "joints_edge.rs"]
|
||||
mod edge;
|
||||
pub use edge::edge_snap;
|
||||
|
||||
#[path = "joints_reach.rs"]
|
||||
mod reach;
|
||||
|
||||
@@ -1737,26 +1556,6 @@ mod face;
|
||||
pub use face::{end_faces, face_name_at, named_face_spot, pin_end, round_end_at};
|
||||
pub use reach::{REACH_TOL, Reach, Reached, reach};
|
||||
|
||||
/// 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> {
|
||||
frame(o, z, x)
|
||||
@@ -1788,7 +1587,6 @@ mod tests {
|
||||
offset: 0.0,
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
})
|
||||
.collect();
|
||||
let links =
|
||||
|
||||
@@ -967,7 +967,6 @@ 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.
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
//! Joint origins on an edge of a planar face (`Snap::Edge`): the edge's middle, or a corner where
|
||||
//! it meets another edge of the face. The edge, the face and the corner's other edge are found
|
||||
//! again by name on every solve, so the origin follows the face as it grows, shrinks or moves:
|
||||
//! hardware set 40 mm in from a panel's corner stays 40 mm in from that corner at any size.
|
||||
//!
|
||||
//! The frame never depends on which way an edge's polyline runs: z is the face's outward normal;
|
||||
//! at the middle y points into the face, square to the edge, and x = y × z runs along it; at a
|
||||
//! corner x and y run along the two edges, away from the corner into the face, in the order that
|
||||
//! makes the frame right-handed.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{Snap, component_bodies, frame};
|
||||
use crate::{DocError, Document, Mat, ModelBody, ModelState, Result};
|
||||
|
||||
/// Points that close are one corner (as a share of the body's size, at least 1 µm).
|
||||
fn tol(b: &ModelBody) -> f64 {
|
||||
(b.body.size() * 2e-3).max(1e-3)
|
||||
}
|
||||
|
||||
/// The edge of `b` nearest `p`, and how far it is.
|
||||
fn nearest_edge(b: &ModelBody, p: Vec3) -> Option<(usize, f64)> {
|
||||
b.mesh()
|
||||
.edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| (i, e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min)))
|
||||
.filter(|(_, d)| d.is_finite())
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
}
|
||||
|
||||
/// Edge `i` of `b`: by its name (`None` when the body has no such edge), else the edge at `pick`.
|
||||
fn edge_index(b: &ModelBody, name: Option<&str>, pick: Vec3) -> Option<usize> {
|
||||
match name {
|
||||
Some(n) => crate::name_lookup::locate_edge(b, n, pick).ok().map(|h| h.edge),
|
||||
None => nearest_edge(b, pick).filter(|(_, d)| *d <= tol(b)).map(|(i, _)| i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The planar face beside edge `i` the origin sits on: the named one, else the larger planar face
|
||||
/// the edge bounds. Its index, outward normal and centroid.
|
||||
fn face_of(b: &ModelBody, i: usize, name: Option<&str>, pick: Vec3) -> Result<(usize, Vec3, Vec3)> {
|
||||
let m = b.mesh();
|
||||
let beside: Vec<usize> = m.edge_faces.get(i).map(|fs| fs.iter().map(|f| *f as usize).collect()).unwrap_or_default();
|
||||
let infos = b.body.faces(tol(b))?;
|
||||
let planar = |f: usize| infos.iter().find(|x| x.index == f).and_then(|x| x.plane_normal.map(|n| (x.index, n, x.centroid, x.area)));
|
||||
let chosen = match name {
|
||||
Some(n) => {
|
||||
let (f, _, _) = crate::naming::face_on_body(b, n, pick)
|
||||
.ok_or_else(|| DocError::Invalid(format!("joint origin: the face `{n}` no longer exists (edit the joint to pick again)")))?;
|
||||
if !beside.contains(&f) {
|
||||
return Err(DocError::Invalid(format!("joint origin: the face `{n}` no longer meets its edge (edit the joint to pick again)")));
|
||||
}
|
||||
planar(f)
|
||||
}
|
||||
None => beside.iter().filter_map(|f| planar(*f)).max_by(|a, b| a.3.total_cmp(&b.3)),
|
||||
};
|
||||
chosen.map(|(f, n, c, _)| (f, n, c)).ok_or_else(|| DocError::Invalid("joint origin: no planar face beside that edge".into()))
|
||||
}
|
||||
|
||||
/// Edge `i`'s middle (along its polyline) and the direction it runs there.
|
||||
fn middle(e: &[Vec3]) -> Option<(Vec3, Vec3)> {
|
||||
let total: f64 = e.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let mut acc = 0.0;
|
||||
for w in e.windows(2) {
|
||||
let l = w[0].dist(w[1]);
|
||||
if l > 0.0 && acc + l >= total / 2.0 {
|
||||
let t = (total / 2.0 - acc) / l;
|
||||
return Some((w[0] + (w[1] - w[0]) * t, (w[1] - w[0]) / l));
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// An edge's end at `p` (within `tol`) and the direction it leaves the end in.
|
||||
fn leaving(e: &[Vec3], p: Vec3, tol: f64) -> Option<Vec3> {
|
||||
let (first, last) = (*e.first()?, *e.last()?);
|
||||
let away = |q: Option<&Vec3>, from: Vec3| q.and_then(|q| (*q - from).normalized());
|
||||
if first.dist(p) <= tol {
|
||||
away(e.iter().find(|q| q.dist(first) > tol * 0.5), first)
|
||||
} else if last.dist(p) <= tol {
|
||||
away(e.iter().rev().find(|q| q.dist(last) > tol * 0.5), last)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The corner where edges `e` and `f` meet: the closest pair of their ends, when within `tol`.
|
||||
fn meeting(e: &[Vec3], f: &[Vec3], tol: f64) -> Option<Vec3> {
|
||||
let ends = |x: &[Vec3]| [x.first().copied(), x.last().copied()];
|
||||
ends(e)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|a| ends(f).into_iter().flatten().map(move |b| (a, a.dist(b))))
|
||||
.filter(|(_, d)| *d <= tol)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(a, _)| a)
|
||||
}
|
||||
|
||||
/// The frame of an edge snap in its component's coordinates.
|
||||
pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap) -> Result<Mat> {
|
||||
let Snap::Edge { pick, edge, face, corner } = snap else { return Err(DocError::Invalid("not an edge origin".into())) };
|
||||
let bad = || DocError::Invalid("joint origin: degenerate frame".into());
|
||||
let gone = |what: &str| DocError::Invalid(format!("joint origin: {what} no longer exists (edit the joint to pick again)"));
|
||||
let found = component_bodies(doc, st, comp).into_iter().find_map(|b| edge_index(b, edge.as_deref(), *pick).map(|i| (b, i)));
|
||||
let (b, i) = found.ok_or_else(|| match edge {
|
||||
Some(n) => gone(&format!("the edge `{n}`")),
|
||||
None => DocError::Invalid("joint origin: no edge there".into()),
|
||||
})?;
|
||||
let m = b.mesh();
|
||||
let e = m.edges.get(i).ok_or_else(bad)?;
|
||||
let (_, z, centroid) = face_of(b, i, face.as_deref(), *pick)?;
|
||||
let flat = |v: Vec3| (v - z * v.dot(z)).normalized();
|
||||
match corner {
|
||||
None => {
|
||||
let (mid, along) = middle(e).ok_or_else(bad)?;
|
||||
// Into the face: toward its centroid, square to the edge; a face whose centroid lies on
|
||||
// the edge's line (only a sliver) falls back to the side z × along gives.
|
||||
let to_c = centroid - mid;
|
||||
let y = flat(to_c - along * to_c.dot(along)).unwrap_or_else(|| z.cross(along));
|
||||
frame(mid, z, Some(y.cross(z))).ok_or_else(bad)
|
||||
}
|
||||
Some(other) => {
|
||||
let j = crate::name_lookup::locate_edge(b, other, *pick).map_err(|_| gone(&format!("the edge `{other}`")))?.edge;
|
||||
let f = m.edges.get(j).ok_or_else(bad)?;
|
||||
let t = tol(b);
|
||||
let at = meeting(e, f, t).ok_or_else(|| DocError::Invalid(format!("joint origin: its edges no longer meet at a corner (`{other}`)")))?;
|
||||
let (de, df) = (leaving(e, at, t).and_then(flat).ok_or_else(bad)?, leaving(f, at, t).and_then(flat).ok_or_else(bad)?);
|
||||
let x = if z.dot(de.cross(df)) >= 0.0 { de } else { df };
|
||||
frame(at, z, Some(x)).ok_or_else(bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge snap for an edge picked at `pick` on component `comp` (its own coordinates): on the
|
||||
/// planar face holding `on` (default: the larger planar face beside the edge), at the edge's
|
||||
/// middle or, with `corner`, at the end nearest it. The edge, the face and the corner's other
|
||||
/// edge are kept by name.
|
||||
pub fn edge_snap(doc: &Document, st: &ModelState, comp: u64, pick: Vec3, on: Option<Vec3>, corner: Option<Vec3>) -> Result<Snap> {
|
||||
let (b, i) = component_bodies(doc, st, comp)
|
||||
.into_iter()
|
||||
.filter_map(|b| nearest_edge(b, pick).filter(|(_, d)| *d <= tol(b)).map(|(i, _)| (b, i)))
|
||||
.next()
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: no edge there".into()))?;
|
||||
let names = crate::naming::edge_names(b);
|
||||
let face_names = crate::naming::face_names(b);
|
||||
let m = b.mesh();
|
||||
let beside: Vec<usize> = m.edge_faces.get(i).map(|fs| fs.iter().map(|f| *f as usize).collect()).unwrap_or_default();
|
||||
let face_index = match on {
|
||||
Some(p) => {
|
||||
let f = crate::naming::face_name_at_body(b, p)
|
||||
.and_then(|n| crate::naming::face_on_body(b, &n, p))
|
||||
.map(|(f, _, _)| f)
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: `on` is not on a face of that body".into()))?;
|
||||
if !beside.contains(&f) {
|
||||
return Err(DocError::Invalid("joint origin: `on` picks a face the edge doesn't bound".into()));
|
||||
}
|
||||
f
|
||||
}
|
||||
None => face_of(b, i, None, pick)?.0,
|
||||
};
|
||||
let face = face_names.get(face_index).cloned().filter(|n| !n.is_empty());
|
||||
let corner = match corner {
|
||||
None => None,
|
||||
Some(c) => {
|
||||
let e = m.edges.get(i).cloned().unwrap_or_default();
|
||||
let t = tol(b);
|
||||
let end = [e.first().copied(), e.last().copied()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min_by(|a, b| a.dist(c).total_cmp(&b.dist(c)))
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: that edge has no ends".into()))?;
|
||||
// The other edge of the face that ends there.
|
||||
let other = m
|
||||
.edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, _)| *j != i && m.edge_faces.get(*j).is_some_and(|fs| fs.contains(&(face_index as u32))))
|
||||
.find(|(_, f)| meeting(&[end], f, t).is_some())
|
||||
.map(|(j, _)| j)
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: no other edge of the face meets that end (a round edge has no corner)".into()))?;
|
||||
Some(
|
||||
names
|
||||
.get(other)
|
||||
.cloned()
|
||||
.filter(|n| !n.is_empty())
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: the corner's other edge has no name".into()))?,
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(Snap::Edge { pick, edge: names.get(i).cloned().filter(|n| !n.is_empty()), face, corner })
|
||||
}
|
||||
@@ -10,7 +10,6 @@ use solidcraft_geom::Vec3;
|
||||
use solidcraft_kernel::CylinderFace;
|
||||
|
||||
use super::{Snap, XRef, component_bodies};
|
||||
use crate::history_names::Match;
|
||||
use crate::{DocError, Document, IDENTITY, Mat, ModelBody, ModelState, Result, apply_point, apply_vector, mat_inverse};
|
||||
|
||||
/// Rim points sampled to find the face across a cylinder's rim.
|
||||
@@ -72,7 +71,7 @@ impl NamedFace<'_> {
|
||||
|
||||
/// A cylinder of a body's own coordinates placed by the body's (rigid) offset.
|
||||
pub(super) fn placed(m: &Mat, c: CylinderFace) -> CylinderFace {
|
||||
CylinderFace { axis_point: apply_point(m, c.axis_point), axis: apply_vector(m, *c.axis).unit().unwrap_or(c.axis), ..c }
|
||||
CylinderFace { axis_point: apply_point(m, c.axis_point), axis: apply_vector(m, c.axis), ..c }
|
||||
}
|
||||
|
||||
/// The face across the rim at the `at` end (along the axis) of cylinder `c`, face `face` of `b`
|
||||
@@ -171,32 +170,21 @@ impl Round {
|
||||
pub(super) fn named_round(doc: &Document, st: &ModelState, pick: Vec3, face: &str, end: Option<&str>) -> Result<Option<Round>> {
|
||||
let nf = named_face(doc, st, pick, face)?;
|
||||
let Some(own) = nf.own_cylinder() else { return Ok(None) };
|
||||
let chosen = end.and_then(|name| end_named(nf.body, nf.face, &own, name)).unwrap_or_else(|| end_by_pick(&nf, &own));
|
||||
let chosen = end
|
||||
.and_then(|name| {
|
||||
let hits: Vec<(f64, f64)> = ends(&own)
|
||||
.into_iter()
|
||||
.filter(|(at, _)| rim_neighbour(nf.body, nf.face, &own, *at).is_some_and(|f| crate::name_lookup::face_has_name(nf.body, f, name)))
|
||||
.collect();
|
||||
match hits[..] {
|
||||
[one] => Some(one),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| end_by_pick(&nf, &own));
|
||||
Ok(Some(Round::at(placed(&nf.offset, own), chosen)))
|
||||
}
|
||||
|
||||
/// The end of cylinder `own` (face `face` of `b`) whose rim meets the face called `name` better
|
||||
/// than the other end's rim does: the very face, or one merged into it, else a piece of it whose
|
||||
/// neighbours have changed (the face round a hole in a sheet is told apart from its other pieces
|
||||
/// by the faces around it, and those change when the hole moves). `None` when neither rim, or
|
||||
/// both, meet it as well.
|
||||
fn end_named(b: &ModelBody, face: usize, own: &CylinderFace, name: &str) -> Option<(f64, f64)> {
|
||||
let names = crate::naming::names(b);
|
||||
let how = |at: f64| {
|
||||
let f = rim_neighbour(b, face, own, at)?;
|
||||
if crate::name_lookup::legacy_name(name).is_some() {
|
||||
return crate::name_lookup::face_has_name(b, f, name).then_some(Match::Exact);
|
||||
}
|
||||
crate::history_names::matches(&names, f, name)
|
||||
};
|
||||
let [start, end] = ends(own);
|
||||
match (how(start.0), how(end.0)) {
|
||||
(s, e) if s > e => Some(start),
|
||||
(s, e) if e > s => Some(end),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The x axis an x reference gives a frame at `o` with axis `z` (`None`: no reference).
|
||||
pub(super) fn x_axis(doc: &Document, st: &ModelState, x: Option<&XRef>, o: Vec3) -> Result<Option<Vec3>> {
|
||||
Ok(match x {
|
||||
|
||||
@@ -486,9 +486,6 @@ pub enum ParamUser {
|
||||
Dimension { feature: u64, name: String, constraint: String },
|
||||
/// A configured column or an expression saved in a configuration row.
|
||||
Configuration { row: Option<String>, column: String },
|
||||
/// A joint's offset, angle or value, or a motion link's gear, rack or screw length (`input`
|
||||
/// says which; a link is named by the joint it drives).
|
||||
Joint { joint: u64, name: String, input: String },
|
||||
}
|
||||
|
||||
impl ParamUser {
|
||||
@@ -501,7 +498,6 @@ impl ParamUser {
|
||||
Some(row) => format!("configuration `{row}` ({column})"),
|
||||
None => format!("configuration column {column}"),
|
||||
},
|
||||
ParamUser::Joint { name, input, .. } => format!("joint {name} ({input})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,12 +615,6 @@ impl Document {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (joint, input, e) in self.assembly.exprs() {
|
||||
if expr::references(e).iter().any(|r| r == name) {
|
||||
let jname = self.assembly.joints.iter().find(|j| j.id == joint).map(|j| j.name.clone()).unwrap_or_else(|| joint.to_string());
|
||||
out.push(ParamUser::Joint { joint, name: jname, input });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -729,13 +719,6 @@ impl Document {
|
||||
}
|
||||
}
|
||||
}
|
||||
for e in self.assembly.exprs_mut() {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if let Some(c) = self.param_comments.remove(old) {
|
||||
self.param_comments.insert(new.to_string(), c);
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
//! Reference triangles belong to a definition, even while its occurrences move. Keep one lazy
|
||||
//! source mesh and map it into each pose; retain the exact placed B-rep separately in ModelBody.
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use solidcraft_geom::{Mesh, Vec3};
|
||||
use solidcraft_kernel::{self as kernel, Body};
|
||||
|
||||
use super::ModelBody;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct PlacedMesh {
|
||||
body: Body,
|
||||
mesh: Arc<OnceLock<Arc<Mesh>>>,
|
||||
transform: crate::Mat,
|
||||
pub(super) tolerance: f64,
|
||||
}
|
||||
|
||||
impl PlacedMesh {
|
||||
/// Share the original definition, never a chain of previously placed meshes. A source's
|
||||
/// OnceLock lives with its immutable geometry, so edits get a new source automatically.
|
||||
pub(super) fn new(source: &ModelBody, transform: &crate::Mat) -> Option<Self> {
|
||||
if !proper_rigid(transform) {
|
||||
return None;
|
||||
}
|
||||
Some(match source.mesh_placement.as_deref() {
|
||||
Some(original) => Self {
|
||||
body: original.body.clone(),
|
||||
mesh: original.mesh.clone(),
|
||||
transform: crate::mat_mul(transform, &original.transform),
|
||||
tolerance: original.tolerance,
|
||||
},
|
||||
None => Self { body: source.body.clone(), mesh: source.mesh.clone(), transform: *transform, tolerance: source.display_tol() },
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn mesh(&self) -> Arc<Mesh> {
|
||||
let source = reference_mesh(&self.body, &self.mesh, self.tolerance);
|
||||
Arc::new(Mesh {
|
||||
positions: source.positions.iter().map(|p| crate::apply_point(&self.transform, *p)).collect(),
|
||||
normals: source.normals.iter().map(|n| crate::apply_vector(&self.transform, *n)).collect(),
|
||||
edges: source.edges.iter().map(|edge| edge.iter().map(|p| crate::apply_point(&self.transform, *p)).collect()).collect(),
|
||||
triangles: source.triangles.clone(),
|
||||
tri_face: source.tri_face.clone(),
|
||||
edge_faces: source.edge_faces.clone(),
|
||||
seams: source.seams.clone(),
|
||||
smooth_edges: source.smooth_edges.clone(),
|
||||
sharp_edges: source.sharp_edges.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reference_mesh(body: &Body, mesh: &OnceLock<Arc<Mesh>>, tolerance: f64) -> Arc<Mesh> {
|
||||
mesh.get_or_init(|| Arc::new(body.reference_display_mesh_angular(tolerance, kernel::DISPLAY_ANGLE).unwrap_or_default())).clone()
|
||||
}
|
||||
|
||||
/// Only proper rotations and translations preserve triangle winding, normal lengths and the
|
||||
/// reference chord tolerance. Scale, shear, reflection and malformed matrices use the mesher.
|
||||
fn proper_rigid(m: &crate::Mat) -> bool {
|
||||
if m.iter().flatten().any(|v| !v.is_finite()) || m[0][3] != 0. || m[1][3] != 0. || m[2][3] != 0. || m[3][3] != 1. {
|
||||
return false;
|
||||
}
|
||||
let [x, y, z] = [Vec3::new(m[0][0], m[0][1], m[0][2]), Vec3::new(m[1][0], m[1][1], m[1][2]), Vec3::new(m[2][0], m[2][1], m[2][2])];
|
||||
[x, y, z].iter().all(|v| (v.dot(*v) - 1.).abs() < 1e-9)
|
||||
&& x.dot(y).abs() < 1e-9
|
||||
&& x.dot(z).abs() < 1e-9
|
||||
&& y.dot(z).abs() < 1e-9
|
||||
&& (x.cross(y).dot(z) - 1.).abs() < 1e-9
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn placed_mesh_is_lazy_and_composes_from_one_immutable_source() {
|
||||
let source = ModelBody::new("Part".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(2., 3., 4.)).unwrap(), 1);
|
||||
let a = crate::rigid(Vec3::new(10., 20., 30.), Vec3::ZERO, Vec3::Z, 0.7);
|
||||
let b = crate::rigid(Vec3::new(3., -5., 2.), Vec3::ZERO, Vec3::X, 0.4);
|
||||
let mut first = ModelBody::new("First".into(), kernel::transform_matrix(&source.body, a).unwrap(), 1);
|
||||
first.mesh_placement = Some(Arc::new(PlacedMesh::new(&source, &a).unwrap()));
|
||||
let mut nested = ModelBody::new("Nested".into(), kernel::transform_matrix(&first.body, b).unwrap(), 1);
|
||||
nested.mesh_placement = Some(Arc::new(PlacedMesh::new(&first, &b).unwrap()));
|
||||
assert_eq!(first.display_tol(), source.display_tol());
|
||||
assert_eq!(nested.display_tol(), source.display_tol());
|
||||
assert!(source.cached_mesh().is_none());
|
||||
assert!(first.cached_mesh().is_none());
|
||||
let mesh = nested.mesh();
|
||||
assert!(source.cached_mesh().is_some(), "the canonical source is warmed on demand");
|
||||
assert!(first.cached_mesh().is_none(), "composition does not retain or evaluate intermediate poses");
|
||||
let local = source.mesh();
|
||||
let matrix = crate::mat_mul(&b, &a);
|
||||
for (p, q) in local.positions.iter().zip(&mesh.positions) {
|
||||
assert!(crate::apply_point(&matrix, *p).dist(*q) < 1e-8);
|
||||
}
|
||||
let (other, work) = kernel::profile::capture(|| first.mesh());
|
||||
assert_eq!(work.tessellate_ms, 0.);
|
||||
assert_eq!(other.tri_face, local.tri_face);
|
||||
assert!(Arc::ptr_eq(&mesh, &nested.mesh()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_instances_warm_one_definition_and_an_edit_replaces_it() {
|
||||
let mut doc = crate::Document::new("instances");
|
||||
let component = doc.add_component(Some("Part"), 0).unwrap();
|
||||
doc.body_components.insert("Pin".into(), component);
|
||||
let first = doc.occurrences[0].id;
|
||||
doc.occurrences[0].transform = crate::rigid(Vec3::new(10., 0., 0.), Vec3::ZERO, Vec3::Y, 0.3);
|
||||
doc.add_occurrence(component, 0, crate::rigid(Vec3::new(0., 20., 0.), Vec3::ZERO, Vec3::X, 0.6)).unwrap();
|
||||
let mut state = crate::ModelState::default();
|
||||
state.bodies.push(ModelBody::new("Pin".into(), kernel::cylinder(Vec3::ZERO, Vec3::Z, 2., 8.).unwrap(), 1));
|
||||
let mut cache = super::super::PlacedBodies::default();
|
||||
let before = super::super::world_state_reusing(&doc, &state, &mut cache);
|
||||
assert!(state.bodies[0].cached_mesh().is_none(), "placing bodies must remain lazy");
|
||||
let old = before.bodies[0].mesh();
|
||||
assert!(state.bodies[0].cached_mesh().is_some());
|
||||
let (_, work) = kernel::profile::capture(|| before.bodies[1].mesh());
|
||||
assert_eq!(work.tessellate_ms, 0., "the second instance shares the first one's triangulation");
|
||||
doc.occurrences.iter_mut().find(|o| o.id == first).unwrap().transform = crate::rigid(Vec3::new(30., 0., 0.), Vec3::ZERO, Vec3::Y, 0.9);
|
||||
let moved = super::super::world_state_reusing(&doc, &state, &mut cache);
|
||||
let (_, work) = kernel::profile::capture(|| moved.bodies[0].mesh());
|
||||
assert_eq!(work.tessellate_ms, 0., "a new pose shares the same definition");
|
||||
state.bodies[0] = ModelBody::new("Pin".into(), kernel::cylinder(Vec3::ZERO, Vec3::Z, 4., 8.).unwrap(), 1);
|
||||
let edited = super::super::world_state_reusing(&doc, &state, &mut cache);
|
||||
assert!(state.bodies[0].cached_mesh().is_none());
|
||||
let new = edited.bodies[0].mesh();
|
||||
assert!(state.bodies[0].cached_mesh().is_some(), "changed geometry warms its new definition cache");
|
||||
assert_ne!(new.bounds(), moved.bodies[0].mesh().bounds());
|
||||
assert!(Arc::ptr_eq(&old, &before.bodies[0].mesh()), "editing does not mutate older snapshots");
|
||||
}
|
||||
#[test]
|
||||
fn only_proper_rigid_affine_matrices_reuse_reference_meshes() {
|
||||
let source = ModelBody::new("Part".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(2., 3., 4.)).unwrap(), 1);
|
||||
assert!(PlacedMesh::new(&source, &crate::IDENTITY).is_some());
|
||||
for (col, row, value) in [(0, 0, -1.), (0, 0, 2.), (0, 1, 0.5), (0, 3, 0.1), (3, 3, 2.), (3, 0, f64::NAN), (3, 2, f64::INFINITY)] {
|
||||
let mut matrix = crate::IDENTITY;
|
||||
matrix[col][row] = value;
|
||||
assert!(PlacedMesh::new(&source, &matrix).is_none(), "{matrix:?}");
|
||||
}
|
||||
// A positive unit determinant alone is insufficient: this scale changes normals and tolerance.
|
||||
let mut scale = crate::IDENTITY;
|
||||
scale[0][0] = 2.;
|
||||
scale[1][1] = 0.5;
|
||||
assert!(PlacedMesh::new(&source, &scale).is_none());
|
||||
}
|
||||
}
|
||||
+27
-61
@@ -3,16 +3,13 @@
|
||||
//!
|
||||
//! Model geometry is found again on every recompute: a face or an edge by the persistent name
|
||||
//! its link keeps, anything else (and a link saved without a name) from the reference point
|
||||
//! stored in the link. An edge of a solid lying in a plane parallel to the sketch's comes in as
|
||||
//! its exact curve (a line, an arc, the Bézier pieces of a free-form curve such as a letter
|
||||
//! cut from a font), so a profile drawn round it meets the solid's faces exactly. Anything
|
||||
//! else is fitted to its polyline from the tessellation, which lies exactly on the curve:
|
||||
//! lines and circular arcs are recovered exactly, the rest (an ellipse from a tilted circle,
|
||||
//! a spline edge running out of the plane) becomes a chain of lines.
|
||||
//! stored in the link. Edge polylines from the tessellation lie exactly on their curves,
|
||||
//! so lines and circular arcs are recovered exactly by fitting; anything else (an ellipse from
|
||||
//! a tilted circle, a spline edge) becomes a chain of lines.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solidcraft_geom::{Mesh, Plane, Seg2, Vec2, Vec3};
|
||||
use solidcraft_geom::{Mesh, Plane, Vec2, Vec3};
|
||||
use solidcraft_sketch::{CurveKind, LinkGeom, LinkKind, LinkSource, Sketch};
|
||||
|
||||
use crate::eval::ModelState;
|
||||
@@ -120,8 +117,7 @@ fn resolve_uncached(
|
||||
let name = st.bodies.get(bi).map(|b| b.name.clone()).unwrap_or_else(|| body.clone());
|
||||
source = LinkSource::Edge { body: name, at: q };
|
||||
edge_index = Some(e);
|
||||
let exact = st.bodies.get(bi).and_then(|b| exact_edges(b, plane, &[e]).pop().flatten());
|
||||
edge_geom(plane, kind, exact, mesh.edges.get(e).cloned().unwrap_or_default(), &mut wires)
|
||||
edge_geom(plane, kind, mesh.edges.get(e).cloned().unwrap_or_default(), &mut wires)
|
||||
}
|
||||
LinkSource::Face { body, at } => {
|
||||
let (bi, mesh) = body_mesh(st, body, *at)?;
|
||||
@@ -137,8 +133,13 @@ fn resolve_uncached(
|
||||
match kind {
|
||||
LinkKind::Intersect => section(plane, &mesh, Some(f)),
|
||||
_ => {
|
||||
let mut g = Vec::new();
|
||||
let target = st.bodies.get(bi).ok_or_else(|| lost("the projection body"))?;
|
||||
let g = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, f, plane)?);
|
||||
for e in projection_face_edges(target, &mesh, f, plane)? {
|
||||
if let Some(pts) = mesh.edges.get(e) {
|
||||
g.extend(fit_world(plane, pts));
|
||||
}
|
||||
}
|
||||
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
|
||||
}
|
||||
}
|
||||
@@ -281,62 +282,19 @@ fn off_plane(plane: &Plane, pts: &[Vec3]) -> bool {
|
||||
pts.iter().any(|p| plane.height(*p).abs() > size * 1e-7)
|
||||
}
|
||||
|
||||
/// What a body edge (its `exact` curve when read, and its polyline `pts`) gives a sketch: its
|
||||
/// section with the sketch plane, itself as a 3D curve (into `wires`) when it is included and
|
||||
/// leaves the plane, else its projection (the exact curve, or the polyline fitted).
|
||||
fn edge_geom(plane: &Plane, kind: LinkKind, exact: Option<Vec<LinkGeom>>, pts: Vec<Vec3>, wires: &mut Vec<Vec<Vec3>>) -> Vec<LinkGeom> {
|
||||
/// What a body edge (its points) gives a sketch: its section with the sketch plane, itself as a
|
||||
/// 3D curve (into `wires`) when it is included and leaves the plane, else its projection.
|
||||
fn edge_geom(plane: &Plane, kind: LinkKind, pts: Vec<Vec3>, wires: &mut Vec<Vec<Vec3>>) -> Vec<LinkGeom> {
|
||||
match kind {
|
||||
LinkKind::Intersect => section_of_polyline(plane, &pts),
|
||||
LinkKind::Include if off_plane(plane, &pts) => {
|
||||
wires.push(pts);
|
||||
Vec::new()
|
||||
}
|
||||
_ => exact.unwrap_or_else(|| fit_world(plane, &pts)),
|
||||
_ => fit_world(plane, &pts),
|
||||
}
|
||||
}
|
||||
|
||||
/// Edges `edges` of `body` (indices into its mesh's `edges`) on `plane` as their exact curves:
|
||||
/// lines, circles and arcs, and the Bézier pieces of a free-form curve. `None` for an edge that
|
||||
/// doesn't lie in a plane parallel to `plane` or isn't read exactly (a mesh body's): it is
|
||||
/// fitted to its polyline instead.
|
||||
fn exact_edges(body: &crate::ModelBody, plane: &Plane, edges: &[usize]) -> Vec<Option<Vec<LinkGeom>>> {
|
||||
body.body.planar_edges(plane, edges).into_iter().map(|segs| segs?.iter().map(link_geom).collect()).collect()
|
||||
}
|
||||
|
||||
/// `edges` of `body` projected on `plane`: each exactly ([`exact_edges`]) or fitted to its
|
||||
/// polyline in `mesh`.
|
||||
fn projected_edges(body: &crate::ModelBody, mesh: &Mesh, plane: &Plane, edges: &[usize]) -> Vec<LinkGeom> {
|
||||
let mut out = Vec::new();
|
||||
for (e, exact) in edges.iter().zip(exact_edges(body, plane, edges)) {
|
||||
match exact {
|
||||
Some(g) => out.extend(g),
|
||||
None => {
|
||||
if let Some(pts) = mesh.edges.get(*e) {
|
||||
out.extend(fit_world(plane, pts));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A boundary segment as the link geometry that is the same curve.
|
||||
fn link_geom(s: &Seg2) -> Option<LinkGeom> {
|
||||
Some(match *s {
|
||||
Seg2::Line { a, b } => LinkGeom::Line(a, b),
|
||||
Seg2::Arc { center, radius, sweep, .. } if sweep.abs() >= std::f64::consts::TAU - 1e-9 => LinkGeom::Circle(center, radius),
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
let at = |t: f64| center + Vec2::new(t.cos(), t.sin()) * radius;
|
||||
// Counter-clockwise from `a` to `b`.
|
||||
let (a, b) = if sweep > 0. { (at(start), at(start + sweep)) } else { (at(start + sweep), at(start)) };
|
||||
LinkGeom::Arc { c: center, a, b }
|
||||
}
|
||||
Seg2::Conic { a, apex, b, w } => LinkGeom::Conic { a, apex, b, rho: w / (1. + w) },
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => LinkGeom::Spline { pts: vec![p0, p1, p2, p3], control: true, degree: 3, periodic: false },
|
||||
Seg2::Bezier { n, p } => LinkGeom::Spline { pts: p.get(..=usize::from(n))?.to_vec(), control: true, degree: n, periodic: false },
|
||||
})
|
||||
}
|
||||
|
||||
/// The persistent name of the edge an `Edge` reference resolved to, for its link to keep and
|
||||
/// be found by from then on. None on a mesh body, whose edges have no names.
|
||||
pub fn resolved_edge_name(st: &ModelState, r: &Resolved) -> Option<String> {
|
||||
@@ -373,8 +331,7 @@ fn resolve_named_edge(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
|
||||
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
|
||||
.ok_or_else(|| lost("named projection edge"))?;
|
||||
let mut wires = Vec::new();
|
||||
let exact = exact_edges(target, plane, &[index]).pop().flatten();
|
||||
let geom = edge_geom(plane, kind, exact, pts, &mut wires);
|
||||
let geom = edge_geom(plane, kind, pts, &mut wires);
|
||||
if geom.is_empty() && wires.is_empty() {
|
||||
return Err(DocError::Invalid("named edge projects to nothing on this sketch plane".into()));
|
||||
}
|
||||
@@ -393,7 +350,12 @@ fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
|
||||
let geom = if kind == LinkKind::Intersect {
|
||||
section(plane, &mesh, Some(index))
|
||||
} else {
|
||||
let geom = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, index, plane)?);
|
||||
let mut geom = Vec::new();
|
||||
for edge in projection_face_edges(target, &mesh, index, plane)? {
|
||||
if let Some(points) = mesh.edges.get(edge) {
|
||||
geom.extend(fit_world(plane, points));
|
||||
}
|
||||
}
|
||||
let geom = merge_split_circles(geom);
|
||||
if certified_planar_construction(target, index as usize) { merge_family_lines(geom)? } else { geom }
|
||||
};
|
||||
@@ -463,7 +425,11 @@ pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body
|
||||
} else {
|
||||
order::family_edges(&mesh, &selected, plane)?
|
||||
};
|
||||
geometry.extend(projected_edges(body, &mesh, plane, &edges));
|
||||
for edge in edges {
|
||||
if let Some(points) = mesh.edges.get(edge) {
|
||||
geometry.extend(fit_world(plane, points));
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind != LinkKind::Intersect && (indices.len() > 1 || indices.first().is_some_and(|index| certified_planar_construction(body, *index))) {
|
||||
geometry = merge_family_lines(geometry)?;
|
||||
|
||||
@@ -290,7 +290,7 @@ fn rule_hem_gap() -> String {
|
||||
}
|
||||
|
||||
impl SheetRule {
|
||||
/// Default: Steel (mm), 2.5 mm, K 0.44, bend radius = Thickness.
|
||||
/// Fusion's default: Steel (mm), 2.5 mm, K 0.44, bend radius = Thickness.
|
||||
pub fn steel() -> SheetRule {
|
||||
SheetRule {
|
||||
name: "Steel (mm)".into(),
|
||||
@@ -1989,7 +1989,7 @@ impl SheetBody {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Topology counted by faces and loops: a round hole is one face bounded by two closed circular
|
||||
/// Topology counted as Fusion does: a round hole is one face bounded by two closed circular
|
||||
/// edges (one vertex each), where the sewn solid splits it into arcs. Returns the change to
|
||||
/// the sewn (faces, edges, vertices).
|
||||
pub fn round_hole_correction(&self) -> (i64, i64, i64) {
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, UnitVec3, Vec2, Vec3};
|
||||
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use solidcraft_kernel::{Body, FaceSurface, face_surface};
|
||||
|
||||
use super::{FlatWork, ReliefOverride, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap};
|
||||
@@ -71,7 +71,7 @@ fn at(p: Vec3) -> String {
|
||||
/// arc where that face is a round wall (`round[i]`: the circle's centre and the arc's middle).
|
||||
/// `holes`: its inner loops, in `plane`, each with the faces of its walls.
|
||||
struct Face {
|
||||
n: UnitVec3,
|
||||
n: Vec3,
|
||||
pts: Vec<Vec3>,
|
||||
across: Vec<Option<usize>>,
|
||||
round: Vec<Option<(Vec3, Vec3)>>,
|
||||
@@ -89,7 +89,7 @@ struct Panel {
|
||||
}
|
||||
|
||||
/// Area of a loop in space, positive when it runs counter-clockwise about `n`.
|
||||
fn area_about(pts: &[Vec3], n: UnitVec3) -> f64 {
|
||||
fn area_about(pts: &[Vec3], n: Vec3) -> f64 {
|
||||
let Some(&o) = pts.first() else { return 0.0 };
|
||||
pts.windows(2).map(|w| (w[0] - o).cross(w[1] - o).dot(n)).sum::<f64>() / 2.0
|
||||
}
|
||||
@@ -227,7 +227,7 @@ fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
|
||||
loops.push(lp);
|
||||
}
|
||||
let mut walls = std::collections::HashMap::new();
|
||||
let mut wall = |g: u32| *walls.entry(g).or_insert_with(|| round_wall(b, &plane, *n, g));
|
||||
let mut wall = |g: u32| *walls.entry(g).or_insert_with(|| round_wall(b, &plane, n, g));
|
||||
// Each loop with the faces of its walls.
|
||||
let mut shaped: Vec<(Vec<(Seg2, Option<u32>)>, Vec<usize>)> = loops
|
||||
.iter()
|
||||
@@ -388,7 +388,7 @@ fn bend_cylinder(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Option<Chamfer> {
|
||||
let Some(FaceSurface::Plane { normal: n, .. }) = face_surface(b, g) else { continue };
|
||||
let radial = (p - origin) - axis * (p - origin).dot(axis);
|
||||
if radial.normalized().is_some_and(|out| n.dot(out).abs() >= 1.0 - 1e-6) {
|
||||
sides.push((g, *n));
|
||||
sides.push((g, n));
|
||||
}
|
||||
}
|
||||
let &[(a, na), (bb, nb)] = sides.as_slice() else { return None };
|
||||
|
||||
@@ -298,14 +298,7 @@ fn measure_pair(
|
||||
));
|
||||
return out;
|
||||
}
|
||||
Ok(m) => {
|
||||
let (wa, wb) = (ca.as_ref().unwrap_or(&ba.body), cb.as_ref().unwrap_or(&bb.body));
|
||||
if let Some(reason) = union_disagrees(wa, wb, m.volume, region.center()) {
|
||||
out.unchecked = Some(reason);
|
||||
return out;
|
||||
}
|
||||
out.overlap = Some((m.volume, m.area, m.bbox, m.centroid));
|
||||
}
|
||||
Ok(m) => out.overlap = Some((m.volume, m.area, m.bbox, m.centroid)),
|
||||
Err(e) => {
|
||||
out.unchecked = Some(e.to_string());
|
||||
return out;
|
||||
@@ -382,42 +375,6 @@ fn measure_near_origin(body: &solidcraft_kernel::Body, at: solidcraft_geom::Vec3
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Does the shared volume of `a` and `b` disagree with the one their union implies,
|
||||
/// vol(a) + vol(b) − vol(a ∪ b)? The intersection can come out well formed and still wrong (a
|
||||
/// mirrored pair read three times its volume). `None` when they agree, or when the union gives no
|
||||
/// answer to compare with.
|
||||
fn union_disagrees(a: &solidcraft_kernel::Body, b: &solidcraft_kernel::Body, shared: f64, at: solidcraft_geom::Vec3) -> Option<String> {
|
||||
let (union, report) = solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(a, b, solidcraft_kernel::BoolOp::Union));
|
||||
let union = union.ok().flatten().filter(|_| report.unverified().is_none())?;
|
||||
let volume = |x: &solidcraft_kernel::Body| measure_near_origin(x, at).ok().map(|m| m.volume).filter(|v| v.is_finite());
|
||||
contradicted(shared, volume(a)?, volume(b)?, volume(&union)?)
|
||||
}
|
||||
|
||||
/// How far vol(a) + vol(b) − vol(a ∪ b) can stray from the shared volume by measuring alone,
|
||||
/// as a share of vol(a) + vol(b) + vol(a ∪ b). [`solidcraft_kernel::measure`] facets a curved
|
||||
/// face with chords of up to 3e-4 of its radius where the radius sets them, which leaves the
|
||||
/// volume it encloses short by up to (4/3) × 3e-4 = 4e-4. That shortfall is the same for a face
|
||||
/// measured on its own body and in the union, and cancels in the difference, except where the
|
||||
/// chord changes with the body measured (it also follows the body's size). Measured, that
|
||||
/// change stays near 2e-5 of the three volumes: 2.1e-5 for a 40 mm roller pressed 0.2 mm into a
|
||||
/// 2 m plate (`a_roller_pressed_into_a_large_plate_is_reported`), 2e-5 and 1e-6 in the examples
|
||||
/// of `the_union_check_allows_for_the_bodies_measuring_error`. This allows five times that.
|
||||
const VOLUME_NOISE: f64 = 1e-4;
|
||||
|
||||
/// [`union_disagrees`] on measured volumes: `shared` against vol(a) + vol(b) − vol(a ∪ b).
|
||||
/// That difference of large numbers carries the measuring error of all three, so a small overlap
|
||||
/// on large bodies can come out near zero or below it: then the union cannot tell, and a shared
|
||||
/// part small next to both bodies stands. A shared part the union clearly contradicts (412 mm³
|
||||
/// where the bodies of a few thousand and their union imply 141) does not.
|
||||
fn contradicted(shared: f64, va: f64, vb: f64, vu: f64) -> Option<String> {
|
||||
let implied = va + vb - vu;
|
||||
let noise = VOLUME_NOISE * (va + vb + vu) + 1e-6;
|
||||
let allowed = 0.01 * shared.max(implied.abs()) + noise;
|
||||
let unresolved = implied <= noise && shared <= 0.1 * va.min(vb);
|
||||
((implied - shared).abs() > allowed && !unresolved)
|
||||
.then(|| format!("the shared part measures {shared:.4} mm³ but the bodies and their union imply {implied:.4} mm³, so neither is reported"))
|
||||
}
|
||||
|
||||
/// Is a measured shared part well formed? A part whose shell came out inside out or open can
|
||||
/// carry a volume with its centre outside its own box, or more volume than its box holds.
|
||||
fn plausible(volume: f64, centre: solidcraft_geom::Vec3, bbox: Aabb3) -> bool {
|
||||
|
||||
@@ -251,74 +251,3 @@ fn a_small_gap_past_a_corner_is_not_contact() {
|
||||
assert!((f(&r["nearest"][a][0]["distance_mm"]) - want).abs() < 1e-9, "{a} {b}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared volume is checked against the bodies and their union: two 10 mm cubes sharing a
|
||||
/// 3 mm corner cube agree at 27 mm³, and a shared part read as three times that (as a mirrored
|
||||
/// pair once was) does not.
|
||||
#[test]
|
||||
fn a_shared_volume_the_union_contradicts_is_caught() {
|
||||
use solidcraft_geom::Vec3;
|
||||
let a = solidcraft_kernel::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 10.0)).unwrap_or_else(|e| panic!("{e}"));
|
||||
let b = solidcraft_kernel::box_solid(Vec3::new(7.0, 7.0, 7.0), Vec3::new(17.0, 17.0, 17.0)).unwrap_or_else(|e| panic!("{e}"));
|
||||
let at = Vec3::new(8.5, 8.5, 8.5);
|
||||
assert_eq!(super::union_disagrees(&a, &b, 27.0, at), None);
|
||||
assert!(super::union_disagrees(&a, &b, 81.0, at).is_some());
|
||||
}
|
||||
|
||||
/// The check works on measured volumes, and vol(a) + vol(b) − vol(a ∪ b) carries the error of
|
||||
/// all three: a small overlap on large bodies can come out near zero, or below it. A union that
|
||||
/// cannot resolve the shared part leaves it standing; one that clearly contradicts it does not.
|
||||
#[test]
|
||||
fn the_union_check_allows_for_the_bodies_measuring_error() {
|
||||
use super::contradicted;
|
||||
// 0.96 mm³ shared where the union of 40 000 and 300 mm³ implies -0.75: noise, not a contradiction.
|
||||
assert_eq!(contradicted(0.96, 40_000.0, 300.0, 40_300.75), None);
|
||||
// 5.92 mm³ of a small body in a large sheet, with the union 0.4 mm³ off the bodies' sum.
|
||||
assert_eq!(contradicted(5.92, 250_000.0, 80.0, 250_074.48), None);
|
||||
// Past the noise, but the union implies nothing at all while the shared part is small next
|
||||
// to both bodies: the union cannot tell, and the shared part stands.
|
||||
assert_eq!(contradicted(6.0, 400.0, 200.0, 600.5), None);
|
||||
// 412 mm³ shared where bodies of a few thousand and their union imply 141: caught.
|
||||
assert!(contradicted(411.98, 3_200.0, 3_150.0, 6_350.0 - 141.18).is_some());
|
||||
// A large shared part the union says is not there at all: caught too.
|
||||
assert!(contradicted(900.0, 3_200.0, 3_150.0, 6_350.0).is_some());
|
||||
}
|
||||
|
||||
/// A small box pressed 0.6 mm into a 2000 × 1000 × 1 mm plate shares 3 × 3 × 0.6 = 5.4 mm³: an
|
||||
/// overlap, not an unchecked pair.
|
||||
#[test]
|
||||
fn a_small_overlap_on_a_large_plate_is_reported() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 2000, "width": 1000, "height": 1, "corner": [0, 0, 0], "operation": "new", "body_name": "Plate"}));
|
||||
run(&mut s, "solid.box", json!({"length": 3, "width": 3, "height": 3, "corner": [700, 400, 0.4], "operation": "new", "body_name": "Box"}));
|
||||
let r = run(&mut s, "inspect.clearance", json!({}));
|
||||
assert_eq!(r["unchecked"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let overlaps = r["overlaps"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(overlaps.len(), 1, "{r}");
|
||||
assert_eq!(pair(&overlaps[0]), ["Box", "Plate"]);
|
||||
assert!((f(&overlaps[0]["volume_mm3"]) - 5.4).abs() < 1e-6, "{r}");
|
||||
}
|
||||
|
||||
/// A 40 mm roller lying on a 2000 × 1000 × 3 mm plate, pressed 0.2 mm into it, shares the
|
||||
/// circular segment along its 10 mm (7.53 mm³). Its curved wall is faceted differently on its
|
||||
/// own and in the union, so vol(a) + vol(b) − vol(a ∪ b) comes out 0.64 mm³ over the shared
|
||||
/// volume (2e-5 of the three volumes, past the 1 % of the shared part): the measuring allowance
|
||||
/// keeps it an overlap, not an unchecked pair.
|
||||
#[test]
|
||||
fn a_roller_pressed_into_a_large_plate_is_reported() {
|
||||
let (r, depth, length) = (20.0_f64, 0.2_f64, 10.0);
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 2000, "width": 1000, "height": 3, "corner": [0, 0, 0], "operation": "new", "body_name": "Plate"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.cylinder",
|
||||
json!({"radius": r, "height": length, "base": [100, 50, 3.0 + r - depth], "axis": [1, 0, 0], "operation": "new", "body_name": "Roller"}),
|
||||
);
|
||||
let res = run(&mut s, "inspect.clearance", json!({}));
|
||||
assert_eq!(res["unchecked"].as_array().map(Vec::len), Some(0), "{res}");
|
||||
let overlaps = res["overlaps"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(overlaps.len(), 1, "{res}");
|
||||
assert_eq!(pair(&overlaps[0]), ["Plate", "Roller"]);
|
||||
let want = (r * r * ((r - depth) / r).acos() - (r - depth) * (r * r - (r - depth) * (r - depth)).sqrt()) * length;
|
||||
assert!((f(&overlaps[0]["volume_mm3"]) - want).abs() < 0.01 * want, "{want} {res}");
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[path = "component_matrix.rs"]
|
||||
pub(super) mod matrix;
|
||||
#[path = "component_properties.rs"]
|
||||
pub(super) mod properties;
|
||||
#[path = "component_transfer.rs"]
|
||||
@@ -54,7 +52,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]. Copies the selected effective transform, including pending moves"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
|
||||
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence).icon("move")
|
||||
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; matrix?: [12 or 16 numbers] (instead of translate/axis/angle/origin: a rigid move as a row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows; one that is a rotation only up to rounding is orthonormalised, a scale, shear or mirror is refused); tolerance?: largest accepted |RᵀR − I| (default 1e-9, at most 1e-3); capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"),
|
||||
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"),
|
||||
CommandSpec::new("occurrence.ground", "Ground", ground).icon("ground").params("occurrence; grounded?: bool (default toggles)"),
|
||||
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
|
||||
@@ -418,28 +416,21 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = s.doc.components.iter().find(|c| c.id == group).map(|c| c.name.clone()).unwrap_or_default();
|
||||
return Err(bad(cmd, format!("{} is held in {name} by a rigid group: move {name}, or Unlock Contents (component.unlock) first", o.name)));
|
||||
}
|
||||
let delta = if let Some(m) = p.get("matrix") {
|
||||
if let Some(k) = ["translate", "axis", "angle", "origin"].into_iter().find(|k| p.get(*k).is_some()) {
|
||||
return Err(bad(cmd, format!("give either matrix or {k}, not both")));
|
||||
}
|
||||
matrix::rigid(m, matrix::tolerance(p, cmd)?, cmd)?
|
||||
} else {
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let axis = move_vector(p, "axis", Vec3::Z, false, cmd)?;
|
||||
let angle = match p.get("angle") {
|
||||
Some(Value::String(e)) => s.doc.eval(e, solidcraft_doc::expr::Kind::Angle).map_err(|e| bad(cmd, e.to_string()))?,
|
||||
None => 0.0,
|
||||
Some(value) => value.as_f64().ok_or_else(|| bad(cmd, "angle must be a number or angle expression"))?,
|
||||
};
|
||||
if !(t.is_finite() && axis.is_finite() && angle.is_finite()) {
|
||||
return Err(bad(cmd, "non-finite move"));
|
||||
}
|
||||
if angle != 0.0 && axis.normalized().is_none() {
|
||||
return Err(bad(cmd, "rotation axis must have a finite nonzero length"));
|
||||
}
|
||||
let origin = move_vector(p, "origin", Vec3::ZERO, true, cmd)?;
|
||||
solidcraft_doc::rigid(t, origin, axis, angle)
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let axis = move_vector(p, "axis", Vec3::Z, false, cmd)?;
|
||||
let angle = match p.get("angle") {
|
||||
Some(Value::String(e)) => s.doc.eval(e, solidcraft_doc::expr::Kind::Angle).map_err(|e| bad(cmd, e.to_string()))?,
|
||||
None => 0.0,
|
||||
Some(value) => value.as_f64().ok_or_else(|| bad(cmd, "angle must be a number or angle expression"))?,
|
||||
};
|
||||
if !(t.is_finite() && axis.is_finite() && angle.is_finite()) {
|
||||
return Err(bad(cmd, "non-finite move"));
|
||||
}
|
||||
if angle != 0.0 && axis.normalized().is_none() {
|
||||
return Err(bad(cmd, "rotation axis must have a finite nonzero length"));
|
||||
}
|
||||
let origin = move_vector(p, "origin", Vec3::ZERO, true, cmd)?;
|
||||
let delta = solidcraft_doc::rigid(t, origin, axis, angle);
|
||||
let space = match p.get("space") {
|
||||
None => "local",
|
||||
Some(Value::String(space)) => space.as_str(),
|
||||
|
||||
@@ -22,7 +22,6 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
|
||||
offset: 2.0,
|
||||
angle: 0.1,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,27 +233,3 @@ fn paste_new_internal_motion_study_drives_only_the_copied_assembly_and_reopens()
|
||||
assert_eq!(reopened.assembly, s.doc.assembly);
|
||||
assert!(reopened.assembly.studies.iter().any(|m| m.name == "Travel (copy)" && m.keys[0].joint == copied_joint));
|
||||
}
|
||||
|
||||
/// An edge origin's edge, face and corner edge are the copy's too.
|
||||
#[test]
|
||||
fn an_edge_origin_is_copied_onto_the_copys_edges() {
|
||||
let (source, mut destination) = documents();
|
||||
let edge = |e: &str| Some(e.to_string());
|
||||
let mut source = source;
|
||||
source.assembly.origins = vec![NamedOrigin {
|
||||
name: "Corner".into(),
|
||||
origin: JointOrigin {
|
||||
occurrence: 1,
|
||||
snap: Snap::Edge { pick: Vec3::new(1.0, 2.0, 3.0), edge: edge("F1:top|F1:side"), face: edge("F1:top"), corner: edge("F1:top|F10:end") },
|
||||
},
|
||||
}];
|
||||
destination.assembly.origins.clear();
|
||||
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new())
|
||||
.unwrap();
|
||||
let copied = destination.assembly.origins.last().map(|o| &o.origin.snap);
|
||||
assert!(
|
||||
matches!(copied, Some(Snap::Edge { edge: Some(e), face: Some(f), corner: Some(c), .. })
|
||||
if e == "F11:top|F11:side" && f == "F11:top" && c == "F11:top|F110:end"),
|
||||
"{copied:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,6 @@ use super::CommandSpec;
|
||||
use crate::params::{bad, str_, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_relink_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod relink_tests;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.insert_design", "Insert Design", insert_design).at("SOLID", "ASSEMBLE").icon("component").params(
|
||||
"path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \
|
||||
@@ -28,9 +23,6 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.update_links", "Update Links", update_links).icon("update").params(
|
||||
"component?: id or name (default: every linked component) — reads the linked designs again and replaces their bodies where they changed",
|
||||
),
|
||||
CommandSpec::new("component.relink", "Relink Source", relink).icon("update").params(
|
||||
"component: id or name of a linked component; path: a saved .solidcraft design — replaces the source and linked bodies for all its occurrences, preserving identities and placements; returns path, previous_path, warnings, joint_errors and joint_conflicts",
|
||||
),
|
||||
CommandSpec::new("component.break_link", "Break Link", break_link)
|
||||
.icon("break")
|
||||
.params("component: id or name of a linked component — keeps its bodies as they are and forgets the design file"),
|
||||
@@ -224,66 +216,6 @@ fn update_links(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"updated": updated, "unchanged": unchanged, "warnings": warnings}))
|
||||
}
|
||||
|
||||
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.relink";
|
||||
let comp = component_key(s, p, "component", cmd)?;
|
||||
let link = s.doc.links.get(&comp).cloned().ok_or_else(|| bad(cmd, "that component is not linked to a design"))?;
|
||||
if !matches!(s.doc.feature(link.feature), Some(f) if f.component == comp && matches!(f.kind, FeatureKind::Import { .. })) {
|
||||
return Err(bad(cmd, "the linked bodies are gone; break the link or insert the design again"));
|
||||
}
|
||||
let given = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))?;
|
||||
let file = absolute(Path::new(given));
|
||||
if !file.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("solidcraft")) {
|
||||
return Err(bad(cmd, "choose a saved .solidcraft design"));
|
||||
}
|
||||
// The ordinary lexical check in linked_bodies also works in the browser VFS. On native
|
||||
// hosts, reject an alias of this assembly too (a symlink or a path with different case).
|
||||
if let (Ok(candidate), Some(Ok(own))) = (file.canonicalize(), s.path.as_deref().map(std::fs::canonicalize))
|
||||
&& candidate == own
|
||||
{
|
||||
return Err(bad(cmd, "a design can't be linked into itself"));
|
||||
}
|
||||
// Read/evaluate the candidate before changing anything. Session::execute owns the single
|
||||
// undo step and restores both the document and evaluated state on any later failure.
|
||||
let (_, sf) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let geometry_changed = match (s.doc.feature(link.feature).map(|f| &f.kind), &sf.kind) {
|
||||
(Some(FeatureKind::Import { step: old, .. }), FeatureKind::Import { step, .. }) => old != step,
|
||||
_ => true,
|
||||
};
|
||||
let path = stored(s.path.as_deref(), &file);
|
||||
let mut warnings = sf.warnings;
|
||||
if geometry_changed
|
||||
&& s.doc
|
||||
.assembly
|
||||
.joints
|
||||
.iter()
|
||||
.any(|j| s.doc.occurrences.iter().any(|o| o.component == comp && (j.a.occurrence == o.id || j.b.occurrence == o.id)))
|
||||
{
|
||||
warnings.push("the linked geometry changed; check this component's joints, even when the solver reports no error".to_string());
|
||||
}
|
||||
if let Some(f) = s.doc_mut().feature_mut(link.feature) {
|
||||
if f.body_names.len() != sf.body_names.len() {
|
||||
f.body_names = sf.body_names;
|
||||
}
|
||||
f.kind = sf.kind;
|
||||
}
|
||||
s.doc_mut().links.insert(comp, DesignLink { path: path.clone(), feature: link.feature });
|
||||
s.refresh();
|
||||
if let Some(error) = s.model.result(link.feature).and_then(|r| r.error.clone()) {
|
||||
return Err(bad(cmd, error));
|
||||
}
|
||||
// Report the solver's diagnostics. A resolved joint can still describe the wrong
|
||||
// connection after a source edit; the reminder above does not depend on solve errors.
|
||||
let sol = solidcraft_doc::joints::solve(&s.doc, &s.model.state());
|
||||
let diagnostics =
|
||||
|rows: &[(u64, String, String)]| rows.iter().map(|(id, name, why)| json!({"joint": id, "name": name, "why": why})).collect::<Vec<_>>();
|
||||
for (_, name, why) in sol.errors.iter().chain(&sol.conflicts) {
|
||||
warnings.push(format!("joint {name}: {why}"));
|
||||
}
|
||||
Ok(json!({"component": comp, "feature": link.feature, "path": path, "previous_path": link.path, "geometry_changed": geometry_changed,
|
||||
"warnings": warnings, "joint_errors": diagnostics(&sol.errors), "joint_conflicts": diagnostics(&sol.conflicts)}))
|
||||
}
|
||||
|
||||
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.break_link";
|
||||
let comp = component_key(s, p, "component", cmd)?;
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
//! Placement given as a matrix: a script's 3×4 (rotation rows and a translation column) or
|
||||
//! 4×4 transform, read as a rigid move. A rotation that is one only up to rounding is
|
||||
//! orthonormalised; a scale, shear or mirror is refused with the reason (a mirror is
|
||||
//! `solid.mirror`'s, not a placement's).
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{IDENTITY, Mat};
|
||||
|
||||
use crate::Result;
|
||||
use crate::params::bad;
|
||||
|
||||
/// The default largest departure from a rotation a matrix may show: |RᵀR − I| per entry.
|
||||
pub const TOLERANCE: f64 = 1e-9;
|
||||
/// The loosest tolerance a caller may ask for.
|
||||
const MAX_TOLERANCE: f64 = 1e-3;
|
||||
|
||||
/// The `tolerance` parameter: the default, or a finite positive number no looser than 1e-3.
|
||||
pub fn tolerance(p: &Value, cmd: &str) -> Result<f64> {
|
||||
match p.get("tolerance") {
|
||||
None | Some(Value::Null) => Ok(TOLERANCE),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|t| t.is_finite() && *t > 0.0 && *t <= MAX_TOLERANCE)
|
||||
.ok_or_else(|| bad(cmd, format!("tolerance must be a number above 0 and at most {MAX_TOLERANCE}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `matrix` as a rigid transform. It is row-major: 12 numbers (three rows of rotation
|
||||
/// and translation, `[r00, r01, r02, tx, r10, …, tz]`) or 16 (a fourth row `0, 0, 0, 1`), flat
|
||||
/// or as rows.
|
||||
pub fn rigid(v: &Value, tolerance: f64, cmd: &str) -> Result<Mat> {
|
||||
let numbers = numbers(v).ok_or_else(|| bad(cmd, "matrix must be 12 numbers (3x4, row by row) or 16 (4x4), flat or as rows"))?;
|
||||
if numbers.iter().any(|x| !x.is_finite()) {
|
||||
return Err(bad(cmd, "matrix must contain finite numbers"));
|
||||
}
|
||||
let at = |row: usize, col: usize| numbers.get(row * 4 + col).copied().unwrap_or(0.0);
|
||||
if numbers.len() == 16 {
|
||||
let last = [at(3, 0), at(3, 1), at(3, 2), at(3, 3) - 1.0];
|
||||
if last.iter().any(|x| x.abs() > tolerance) {
|
||||
return Err(bad(cmd, "matrix is not affine: its fourth row must be 0, 0, 0, 1 (a projective transform is not a placement)"));
|
||||
}
|
||||
}
|
||||
if (0..3).any(|r| at(r, 3).abs() > 1e9) {
|
||||
return Err(bad(cmd, "matrix translation exceeds supported numeric range"));
|
||||
}
|
||||
// r[row][col], then |RᵀR − I| measures how far it is from a rotation.
|
||||
let r: [[f64; 3]; 3] = std::array::from_fn(|row| std::array::from_fn(|col| at(row, col)));
|
||||
let deviation = (0..3)
|
||||
.flat_map(|i| (0..3).map(move |j| (i, j)))
|
||||
.map(|(i, j)| {
|
||||
let dot: f64 = (0..3).map(|k| r[k][i] * r[k][j]).sum();
|
||||
(dot - if i == j { 1.0 } else { 0.0 }).abs()
|
||||
})
|
||||
.fold(0.0, f64::max);
|
||||
let det = r[0][0] * (r[1][1] * r[2][2] - r[1][2] * r[2][1]) - r[0][1] * (r[1][0] * r[2][2] - r[1][2] * r[2][0])
|
||||
+ r[0][2] * (r[1][0] * r[2][1] - r[1][1] * r[2][0]);
|
||||
if det < 0.0 {
|
||||
return Err(bad(cmd, "matrix mirrors (its determinant is negative): a placement only turns and moves; mirror bodies with solid.mirror"));
|
||||
}
|
||||
if !deviation.is_finite() || deviation > tolerance {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("matrix is not rigid: it scales or shears by {deviation:.3e} (|RᵀR − I|), above the tolerance {tolerance:.0e}"),
|
||||
));
|
||||
}
|
||||
let rot = orthonormal(r);
|
||||
let mut m = IDENTITY;
|
||||
for (col, column) in m.iter_mut().enumerate().take(3) {
|
||||
for (row, cell) in column.iter_mut().enumerate().take(3) {
|
||||
*cell = rot[row][col];
|
||||
}
|
||||
}
|
||||
m[3] = [at(0, 3), at(1, 3), at(2, 3), 1.0];
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// The matrix's numbers, row by row: 12 or 16 of them, flat or nested as 3 or 4 rows of 4.
|
||||
fn numbers(v: &Value) -> Option<Vec<f64>> {
|
||||
let a = v.as_array()?;
|
||||
let flat: Vec<f64> = if a.iter().all(Value::is_array) {
|
||||
if !matches!(a.len(), 3 | 4) {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(16);
|
||||
for row in a {
|
||||
let row = row.as_array().filter(|r| r.len() == 4)?;
|
||||
for x in row {
|
||||
out.push(x.as_f64()?);
|
||||
}
|
||||
}
|
||||
out
|
||||
} else {
|
||||
a.iter().map(Value::as_f64).collect::<Option<_>>()?
|
||||
};
|
||||
matches!(flat.len(), 12 | 16).then_some(flat)
|
||||
}
|
||||
|
||||
/// The rotation nearest to `r` (its polar factor), by Newton's iteration R ← (R + R⁻ᵀ) / 2,
|
||||
/// which converges quadratically from a near-rotation; a few rounds reach machine precision.
|
||||
fn orthonormal(mut r: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
|
||||
for _ in 0..4 {
|
||||
let Some(inv_t) = inverse_transpose(&r) else { break };
|
||||
for (row, inv_row) in r.iter_mut().zip(inv_t) {
|
||||
for (x, y) in row.iter_mut().zip(inv_row) {
|
||||
*x = 0.5 * (*x + y);
|
||||
}
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// (R⁻¹)ᵀ: the cofactor matrix over the determinant.
|
||||
fn inverse_transpose(r: &[[f64; 3]; 3]) -> Option<[[f64; 3]; 3]> {
|
||||
let a = |i: usize, j: usize| r[i % 3][j % 3];
|
||||
let cof: [[f64; 3]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| a(i + 1, j + 1) * a(i + 2, j + 2) - a(i + 1, j + 2) * a(i + 2, j + 1)));
|
||||
let det: f64 = (0..3).map(|j| r[0][j] * cof[0][j]).sum();
|
||||
if !det.is_finite() || det.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
Some(cof.map(|row| row.map(|x| x / det)))
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
|
||||
fn folder(case: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-relink-{case}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn part(path: &Path, height: u32) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": height, "corner": [0,0,0]}));
|
||||
run(&mut s, "file.save", json!({"path": path}));
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relink_repairs_a_moved_source_for_every_instance_in_one_undo_step() {
|
||||
let dir = folder("moved");
|
||||
let old = dir.join("old.solidcraft");
|
||||
let moved = dir.join("moved.solidcraft");
|
||||
let assembly = dir.join("assembly.solidcraft");
|
||||
part(&old, 30);
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "file.save", json!({"path": assembly}));
|
||||
let inserted = run(&mut s, "component.insert_design", json!({"path": old}));
|
||||
let component = inserted["component"].as_u64().unwrap();
|
||||
let first = inserted["occurrence"].as_u64().unwrap();
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence": first, "translate": [70,30,10]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [0,12,0], "capture": false}));
|
||||
let occurrences = s.doc.occurrences.clone();
|
||||
let pending = s.pending_moves.clone();
|
||||
let before = serde_json::to_value(&*s.doc).unwrap();
|
||||
let undo = s.undo.len();
|
||||
std::fs::rename(&old, &moved).unwrap();
|
||||
part(&moved, 45);
|
||||
let result = run(&mut s, "component.relink", json!({"component": component, "path": moved}));
|
||||
assert_eq!(result["previous_path"], "old.solidcraft");
|
||||
assert_eq!(result["path"], "moved.solidcraft");
|
||||
assert_eq!(result["geometry_changed"], true);
|
||||
assert_eq!(result["warnings"], json!([]));
|
||||
assert_eq!(s.doc.occurrences, occurrences);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert!((volume(&mut s) - 18000.0).abs() < 1e-5);
|
||||
let after = serde_json::to_value(&*s.doc).unwrap();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&*s.doc).unwrap(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert!((volume(&mut s) - 12000.0).abs() < 1e-5);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&*s.doc).unwrap(), after);
|
||||
run(&mut s, "file.save", json!({}));
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "doc.open", json!({"path": assembly}));
|
||||
assert_eq!(run(&mut reopened, "component.links", json!({}))["links"][0]["path"], "moved.solidcraft");
|
||||
assert_eq!(run(&mut reopened, "component.links", json!({}))["links"][0]["found"], true);
|
||||
assert!((volume(&mut reopened) - 18000.0).abs() < 1e-5);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relink_rejects_bad_sources_and_targets_without_touching_the_design_or_undo() {
|
||||
let dir = folder("failures");
|
||||
let source = dir.join("source.solidcraft");
|
||||
let assembly = dir.join("assembly.solidcraft");
|
||||
let empty = dir.join("empty.solidcraft");
|
||||
let corrupt = dir.join("corrupt.solidcraft");
|
||||
part(&source, 30);
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.insert_design", json!({"path": source}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "file.save", json!({"path": assembly}));
|
||||
run(&mut Session::default(), "file.save", json!({"path": empty}));
|
||||
std::fs::write(&corrupt, b"not a design").unwrap();
|
||||
let before = serde_json::to_value(&*s.doc).unwrap();
|
||||
let undo = s.undo.len();
|
||||
for params in [
|
||||
json!({}),
|
||||
json!({"component": component}),
|
||||
json!({"component": false, "path": source}),
|
||||
json!({"component": 0, "path": source}),
|
||||
json!({"component": "unknown", "path": source}),
|
||||
json!({"component": component, "path": " "}),
|
||||
json!({"component": component, "path": false}),
|
||||
json!({"component": component, "path": dir.join("missing.solidcraft")}),
|
||||
json!({"component": component, "path": dir.join("source.step")}),
|
||||
json!({"component": component, "path": empty}),
|
||||
json!({"component": component, "path": corrupt}),
|
||||
json!({"component": component, "path": assembly}),
|
||||
] {
|
||||
assert!(s.execute("component.relink", ¶ms).is_err(), "{params}");
|
||||
assert_eq!(serde_json::to_value(&*s.doc).unwrap(), before, "{params}");
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert!((volume(&mut s) - 6000.0).abs() < 1e-5);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relink_keeps_joint_definitions_and_reports_solver_diagnostics() {
|
||||
let dir = folder("joints");
|
||||
let original = dir.join("original.solidcraft");
|
||||
let replacement = dir.join("replacement.solidcraft");
|
||||
part(&original, 30);
|
||||
part(&replacement, 45);
|
||||
let mut s = Session::default();
|
||||
let inserted = run(&mut s, "component.insert_design", json!({"path": original}));
|
||||
let component = inserted["component"].as_u64().unwrap();
|
||||
let occurrence = inserted["occurrence"].as_u64().unwrap();
|
||||
// Explicit points can keep solving after the source changes, even if the intended
|
||||
// connection no longer exists. The source-change warning must still be present.
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"name": "Seat", "type": "rigid", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": occurrence, "point": [0,0,0]}}),
|
||||
);
|
||||
let joints = s.doc.assembly.joints.clone();
|
||||
let identical = dir.join("identical.solidcraft");
|
||||
std::fs::copy(&original, &identical).unwrap();
|
||||
let result = run(&mut s, "component.relink", json!({"component": component, "path": identical}));
|
||||
assert_eq!(result["geometry_changed"], false, "renaming a source is not a geometry change");
|
||||
assert_eq!(result["warnings"], json!([]));
|
||||
assert_eq!(s.doc.assembly.joints, joints);
|
||||
let result = run(&mut s, "component.relink", json!({"component": component, "path": replacement}));
|
||||
assert_eq!(s.doc.assembly.joints, joints);
|
||||
assert!(result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("check this component's joints"))));
|
||||
// A deliberately lost named face exercises the ordinary solver diagnostics, without
|
||||
// requiring relink to guess which face of a different design was intended.
|
||||
let joint = s.doc_mut().assembly.joints.first_mut().unwrap();
|
||||
joint.b.snap =
|
||||
solidcraft_doc::joints::Snap::FaceCenter { pick: solidcraft_geom::Vec3::ZERO, face: Some("no-such-linked-face".to_string()), x: None };
|
||||
s.refresh();
|
||||
let result = run(&mut s, "component.relink", json!({"component": component, "path": replacement}));
|
||||
assert!(result["joint_errors"].as_array().unwrap().iter().any(|e| e["name"] == "Seat"));
|
||||
assert!(result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("joint Seat:"))));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -148,10 +148,10 @@ fn transferred_thread_follows_body_frame_without_changing_world_axis() {
|
||||
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Shaft"]}));
|
||||
let after = s.world_state().threads[0].clone();
|
||||
assert!(after.axis_point.dist(before.axis_point) < 1e-6);
|
||||
assert!(after.axis.dist(*before.axis) < 1e-6);
|
||||
assert!(after.axis.dist(before.axis) < 1e-6);
|
||||
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.world_state().threads[0].axis.dist(*before.axis) < 1e-6);
|
||||
assert!(reopened.world_state().threads[0].axis.dist(before.axis) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+29
-226
@@ -25,7 +25,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("delete")
|
||||
.key("Delete")
|
||||
.params("feature: id|name | features: [id|name] (one undo step; a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
|
||||
.params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
|
||||
CommandSpec::new("timeline.rename", "Rename", rename).icon("rename").params("feature: id|name, name (references to it follow)"),
|
||||
CommandSpec::new("timeline.group", "New Feature Folder", group)
|
||||
.icon("folder")
|
||||
@@ -41,9 +41,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
|
||||
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block",
|
||||
),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).icon("rollback").params("position: number of features to keep active, or \"end\" (omit = end)"),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).icon("rollback").params("position: number of features to keep active (omit = end)"),
|
||||
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback).icon("rollback")
|
||||
.params("position: number of features to keep active, or \"end\" (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder).icon("reorder")
|
||||
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
|
||||
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).icon("reorder").params(
|
||||
@@ -54,15 +54,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents).icon("graph")
|
||||
.noundo()
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).icon("suppress").params(
|
||||
"feature: id|name | features: [id|name] (one undo step), suppressed?: bool (default toggles: several features are all \
|
||||
suppressed unless all of them already are, then all are unsuppressed) → feature: id (or features: [ids]), suppressed",
|
||||
),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).icon("suppress").params("feature: id|name, suppressed?: bool (default toggles)"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).icon("edit").params(
|
||||
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
|
||||
e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \
|
||||
a dotted key names a nested field ({\"plane.distance\": \"8 mm\"} = {\"plane\": {\"distance\": \"8 mm\"}}); \
|
||||
expressions are text, or a number in mm or degrees} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
|
||||
expressions are text} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
|
||||
),
|
||||
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).icon("inspect").noundo().params(
|
||||
"feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
|
||||
@@ -513,17 +509,7 @@ fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = feature_id(s, Some(f), "timeline.roll_to")?;
|
||||
s.doc.feature_index(id).map(|i| i + 1)
|
||||
}
|
||||
// A number of features, or "end" (as omitting it); anything else is refused rather than
|
||||
// read as "end".
|
||||
None => match p.get("position") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(t)) if t.trim().eq_ignore_ascii_case("end") => None,
|
||||
Some(v) => {
|
||||
let x = v.as_f64().or_else(|| v.as_str().and_then(|t| t.trim().parse::<f64>().ok())).and_then(crate::params::finite);
|
||||
let why = "`position` must be a number of features to keep active, or \"end\"";
|
||||
Some(x.map(|x| x.max(0.0) as usize).ok_or_else(|| bad("timeline.roll_to", why))?)
|
||||
}
|
||||
},
|
||||
None => num(p, "position").map(|x| x.max(0.0) as usize),
|
||||
};
|
||||
s.doc_mut().marker = match pos {
|
||||
Some(k) if k < n => Some(k),
|
||||
@@ -533,52 +519,12 @@ fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"marker": s.doc.marker.unwrap_or(n), "features": n}))
|
||||
}
|
||||
|
||||
/// The features named by `feature: id|name` and/or `features: [id|name]` (or one id|name), in
|
||||
/// the order given and without repeats. Each must exist.
|
||||
fn feature_ids(s: &Session, p: &Value, cmd: &str) -> Result<Vec<u64>> {
|
||||
let mut given: Vec<&Value> = Vec::new();
|
||||
if let Some(f) = p.get("feature").filter(|v| !v.is_null()) {
|
||||
given.push(f);
|
||||
}
|
||||
match p.get("features") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Array(a)) if a.len() <= 10_000 => given.extend(a),
|
||||
Some(Value::Array(_)) => return Err(bad(cmd, "too many features")),
|
||||
Some(one @ (Value::Number(_) | Value::String(_))) => given.push(one),
|
||||
Some(_) => return Err(bad(cmd, "`features` must be a list of feature ids or names")),
|
||||
}
|
||||
if given.is_empty() {
|
||||
return Err(bad(cmd, "`feature` (id or name) or `features` (a list of them) must name a feature"));
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for v in given {
|
||||
let id = feature_id(s, Some(v), cmd)?;
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn suppress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.suppress";
|
||||
let ids = feature_ids(s, p, cmd)?;
|
||||
let want = match bool_(p, "suppressed") {
|
||||
Some(b) => b,
|
||||
None if p.get("suppressed").is_some_and(|v| !v.is_null()) => return Err(bad(cmd, "`suppressed` must be true or false")),
|
||||
// Toggle: one feature flips; several are all suppressed unless all already are.
|
||||
None => !ids.iter().all(|id| s.doc.feature(*id).is_some_and(|f| f.suppressed)),
|
||||
};
|
||||
// One command, so one undo step however many features it changes.
|
||||
let doc = s.doc_mut();
|
||||
for id in &ids {
|
||||
let f = doc.feature_mut(*id).ok_or_else(|| bad(cmd, format!("no feature {id}")))?;
|
||||
f.suppressed = want;
|
||||
}
|
||||
match ids.as_slice() {
|
||||
[id] if p.get("features").is_none_or(Value::is_null) => Ok(json!({"feature": id, "suppressed": want})),
|
||||
_ => Ok(json!({"features": ids, "suppressed": want})),
|
||||
}
|
||||
let id = feature_id(s, p.get("feature"), "timeline.suppress")?;
|
||||
let want = bool_(p, "suppressed");
|
||||
let f = s.doc_mut().feature_mut(id).ok_or_else(|| EngineError::Other("feature".into()))?;
|
||||
f.suppressed = want.unwrap_or(!f.suppressed);
|
||||
Ok(json!({"feature": id, "suppressed": f.suppressed}))
|
||||
}
|
||||
|
||||
fn merge(dst: &mut Value, src: &Value, depth: usize) {
|
||||
@@ -687,20 +633,17 @@ fn unknown_field(
|
||||
}
|
||||
|
||||
/// "unknown field `offset`; ConstructionPlane has `plane.base.name`, `plane.distance`…": the
|
||||
/// editable fields where it was given, as paths (each one timeline.edit's `set` takes as a key).
|
||||
fn unknown_field_message(field: &[Step], def: &Value, kind: &FeatureKind) -> String {
|
||||
let type_name = kind.type_name();
|
||||
let heavy = heavy_fields(kind);
|
||||
let skip = |k: &str| NOT_EDITABLE.contains(&k) || heavy.iter().any(|(h, _)| *h == k);
|
||||
fn leaves(v: &Value, prefix: &str, skip: Option<&dyn Fn(&str) -> bool>, depth: usize, out: &mut Vec<String>) {
|
||||
/// editable fields where it was given, as paths.
|
||||
fn unknown_field_message(field: &[Step], def: &Value, type_name: &str) -> String {
|
||||
fn leaves(v: &Value, prefix: &str, top: bool, depth: usize, out: &mut Vec<String>) {
|
||||
match v {
|
||||
Value::Object(o) if depth < 4 && !o.is_empty() => {
|
||||
for (k, x) in o {
|
||||
if skip.is_some_and(|skip| skip(k)) {
|
||||
if top && NOT_EDITABLE.contains(&k.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let at = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") };
|
||||
leaves(x, &at, None, depth + 1, out);
|
||||
leaves(x, &at, false, depth + 1, out);
|
||||
}
|
||||
}
|
||||
_ if !prefix.is_empty() => out.push(format!("`{prefix}`")),
|
||||
@@ -710,8 +653,7 @@ fn unknown_field_message(field: &[Step], def: &Value, kind: &FeatureKind) -> Str
|
||||
let parent = field.split_last().map(|(_, p)| p).unwrap_or_default();
|
||||
let mut known = Vec::new();
|
||||
if let Some(at) = at_path(def, parent) {
|
||||
let top: Option<&dyn Fn(&str) -> bool> = if parent.is_empty() { Some(&skip) } else { None };
|
||||
leaves(at, &path_text(parent), top, 0, &mut known);
|
||||
leaves(at, &path_text(parent), parent.is_empty(), 0, &mut known);
|
||||
}
|
||||
const SHOWN: usize = 40;
|
||||
let more = known.len().saturating_sub(SHOWN);
|
||||
@@ -727,140 +669,6 @@ fn unknown_field_message(field: &[Step], def: &Value, kind: &FeatureKind) -> Str
|
||||
format!("unknown field `{field}`; {type_name} has {list} (timeline.inspect shows them with their values)")
|
||||
}
|
||||
|
||||
/// `set` with dotted keys (`"plane.distance"`, as timeline.edit's unknown-field message lists the
|
||||
/// fields) expanded into the nested form (`{"plane": {"distance": …}}`), merged with any nested
|
||||
/// form given next to them. A key the definition itself has at that level stays as it is. A field
|
||||
/// given twice (`plane.distance` and `plane: {distance}`) is refused.
|
||||
fn expand_dotted(set: &Value, def: Option<&Value>, cmd: &str, depth: usize) -> Result<Value> {
|
||||
if depth > 16 {
|
||||
return Ok(set.clone());
|
||||
}
|
||||
match set {
|
||||
Value::Object(o) => {
|
||||
let mut nested = serde_json::Map::new();
|
||||
for (k, v) in o {
|
||||
let parts: Vec<&str> = k.split('.').collect();
|
||||
let literal = parts.len() == 1 || parts.len() > 16 || parts.iter().any(|x| x.is_empty()) || def.and_then(|d| d.get(k)).is_some();
|
||||
let parts = if literal { vec![k.as_str()] } else { parts };
|
||||
insert_field(&mut nested, &parts, v.clone(), k, cmd)?;
|
||||
}
|
||||
let mut out = serde_json::Map::new();
|
||||
for (k, v) in nested {
|
||||
let d = def.and_then(|d| d.get(&k));
|
||||
out.insert(k, expand_dotted(&v, d, cmd, depth + 1)?);
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
Value::Array(a) => a
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| expand_dotted(v, def.and_then(|d| d.get(i)), cmd, depth + 1))
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(Value::Array),
|
||||
_ => Ok(set.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `v` at `parts` in `out`, creating the objects on the way; `key` is the field as given.
|
||||
fn insert_field(out: &mut serde_json::Map<String, Value>, parts: &[&str], v: Value, key: &str, cmd: &str) -> Result<()> {
|
||||
let twice = || bad(cmd, format!("`{key}` is given twice (as a dotted key and in the nested form)"));
|
||||
let Some((last, parents)) = parts.split_last() else { return Ok(()) };
|
||||
let mut at = out;
|
||||
for part in parents {
|
||||
let slot = at.entry(part.to_string()).or_insert_with(|| Value::Object(serde_json::Map::new()));
|
||||
at = slot.as_object_mut().ok_or_else(twice)?;
|
||||
}
|
||||
match (at.get_mut(*last), v) {
|
||||
(None, v) => {
|
||||
at.insert(last.to_string(), v);
|
||||
}
|
||||
(Some(Value::Object(slot)), Value::Object(more)) => {
|
||||
for (k, x) in more {
|
||||
insert_field(slot, &[k.as_str()], x, key, cmd)?;
|
||||
}
|
||||
}
|
||||
(Some(_), _) => return Err(twice()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn at_path_mut<'a>(v: &'a mut Value, path: &[Step]) -> Option<&'a mut Value> {
|
||||
path.iter().try_fold(v, |v, s| match s {
|
||||
Step::Key(k) => v.get_mut(k),
|
||||
Step::Index(i) => v.get_mut(*i),
|
||||
})
|
||||
}
|
||||
|
||||
/// Paths of the finite numbers in `set` where the definition (`before`) has no number.
|
||||
fn number_leaves(set: &Value, before: &Value, path: &mut Vec<Step>, out: &mut Vec<Vec<Step>>, depth: usize) {
|
||||
if depth > 16 || out.len() >= 1_000 {
|
||||
return;
|
||||
}
|
||||
match set {
|
||||
Value::Object(o) => {
|
||||
for (k, v) in o {
|
||||
path.push(Step::Key(k.clone()));
|
||||
number_leaves(v, before, path, out, depth + 1);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
Value::Array(a) => {
|
||||
for (i, v) in a.iter().enumerate().take(10_000) {
|
||||
path.push(Step::Index(i));
|
||||
number_leaves(v, before, path, out, depth + 1);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
Value::Number(n) if crate::params::number_text(n).is_some() && !matches!(at_path(before, path), Some(Value::Number(_))) => {
|
||||
out.push(path.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finite numbers given for expression fields become the expression's text (`8` → `"8"`, mm or
|
||||
/// degrees by context), as the feature commands take them (`params::expr`). Which fields are
|
||||
/// expressions is what the feature's own walk says: each number is put in as a marker text, and
|
||||
/// the markers the feature then shows among its expressions are expression fields. Numbers
|
||||
/// anywhere else stay numbers.
|
||||
fn numbers_as_expressions(mut set: Value, before: &Value) -> Value {
|
||||
let mut found = Vec::new();
|
||||
number_leaves(&set, before, &mut Vec::new(), &mut found, 0);
|
||||
if found.is_empty() {
|
||||
return set;
|
||||
}
|
||||
let marker = |i: usize| format!("\u{1}number {i}");
|
||||
// The markers among the feature's expressions with the numbers at `which` marked, or None
|
||||
// when the feature doesn't read that way.
|
||||
let expressions = |which: &[usize]| -> Option<std::collections::BTreeSet<String>> {
|
||||
let mut marked = set.clone();
|
||||
for &i in which {
|
||||
if let Some(slot) = found.get(i).and_then(|path| at_path_mut(&mut marked, path)) {
|
||||
*slot = Value::String(marker(i));
|
||||
}
|
||||
}
|
||||
let mut v = before.clone();
|
||||
merge(&mut v, &marked, 0);
|
||||
let f: Feature = serde_json::from_value(v).ok()?;
|
||||
Some(f.kind.expressions().into_iter().filter(|e| e.starts_with('\u{1}')).map(str::to_string).collect())
|
||||
};
|
||||
let all: Vec<usize> = (0..found.len()).collect();
|
||||
let hits: Vec<usize> = match expressions(&all) {
|
||||
Some(e) => all.into_iter().filter(|i| e.contains(&marker(*i))).collect(),
|
||||
// Some of them are numbers after all: one at a time.
|
||||
None => all.into_iter().take(64).filter(|i| expressions(&[*i]).is_some_and(|e| e.contains(&marker(*i)))).collect(),
|
||||
};
|
||||
for i in hits {
|
||||
if let Some(slot) = found.get(i).and_then(|path| at_path_mut(&mut set, path))
|
||||
&& let Value::Number(n) = slot
|
||||
&& let Some(text) = crate::params::number_text(n)
|
||||
{
|
||||
*slot = Value::String(text);
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// `profiles` (also a loft section's) given as the feature commands take it (`"all"`,
|
||||
/// `[index]`, `[{curves}]`, `[{point}]`...) becomes the stored form; the stored form passes.
|
||||
fn command_profiles(set: &Value, cmd: &str) -> Result<Value> {
|
||||
@@ -888,17 +696,6 @@ fn command_profiles(set: &Value, cmd: &str) -> Result<Value> {
|
||||
/// the name and suppression, which timeline.rename and timeline.suppress change.
|
||||
const NOT_EDITABLE: &[&str] = &["id", "type", "name", "suppressed", "param_names", "edge_refs", "edge_names", "face_names"];
|
||||
|
||||
/// Large payloads that timeline.edit cannot change, left out of a feature's definition: (field,
|
||||
/// what it holds).
|
||||
fn heavy_fields(kind: &FeatureKind) -> &'static [(&'static str, &'static str)] {
|
||||
match kind {
|
||||
FeatureKind::Sketch { .. } => &[("sketch", "the sketch geometry (sketch.inspect; edit it with the sketch commands)")],
|
||||
FeatureKind::Import { .. } => &[("step", "the imported STEP text"), ("components", "the imported product tree")],
|
||||
FeatureKind::MeshImport { .. } => &[("meshes", "the imported meshes")],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.inspect";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
@@ -909,7 +706,14 @@ fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for k in NOT_EDITABLE {
|
||||
o.remove(*k);
|
||||
}
|
||||
for (k, why) in heavy_fields(&f.kind) {
|
||||
// Large payloads that timeline.edit cannot change.
|
||||
let heavy: &[(&str, &str)] = match &f.kind {
|
||||
FeatureKind::Sketch { .. } => &[("sketch", "the sketch geometry (sketch.inspect; edit it with the sketch commands)")],
|
||||
FeatureKind::Import { .. } => &[("step", "the imported STEP text"), ("components", "the imported product tree")],
|
||||
FeatureKind::MeshImport { .. } => &[("meshes", "the imported meshes")],
|
||||
_ => &[],
|
||||
};
|
||||
for (k, why) in heavy {
|
||||
if o.remove(*k).is_some() {
|
||||
omitted.push(json!({"field": k, "what": why}));
|
||||
}
|
||||
@@ -940,10 +744,8 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let set = p.get("set").filter(|v| v.is_object()).ok_or_else(|| bad(cmd, "`set` must be an object of fields"))?;
|
||||
let f = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let before = serde_json::to_value(&f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let set = expand_dotted(set, Some(&before), cmd, 0)?;
|
||||
let set = &numbers_as_expressions(command_profiles(&set, cmd)?, &before);
|
||||
let mut v = before.clone();
|
||||
let mut v = serde_json::to_value(&f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let set = &command_profiles(set, cmd)?;
|
||||
merge(&mut v, set, 0);
|
||||
let mut nf: Feature = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
|
||||
nf.id = f.id;
|
||||
@@ -960,8 +762,9 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// serde drops fields the definition doesn't have without a word: refuse them, or a typo
|
||||
// would report success and change nothing.
|
||||
let after = serde_json::to_value(&nf).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let before = serde_json::to_value(&f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let Some(field) = unknown_field(set, Some(&before), Some(&after), &v, &mut Vec::new(), 0) {
|
||||
return Err(bad(cmd, unknown_field_message(&field, &before, &f.kind)));
|
||||
return Err(bad(cmd, unknown_field_message(&field, &before, f.kind.type_name())));
|
||||
}
|
||||
if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &nf.kind {
|
||||
solidcraft_doc::validate_body_transfer(&s.doc, &solidcraft_doc::plain(bodies), *from, *to, transform)?;
|
||||
|
||||
@@ -8,28 +8,6 @@ fn run(s: &mut Session, id: &str, params: Value) -> Value {
|
||||
s.execute(id, ¶ms).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
|
||||
}
|
||||
|
||||
/// `timeline.roll_to` takes a number of features or "end"; any other value is refused instead of
|
||||
/// silently moving the marker to the end.
|
||||
#[test]
|
||||
fn roll_to_takes_a_number_or_end_and_refuses_anything_else() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 20, "operation": "join"}));
|
||||
for cmd in ["timeline.roll_to", "timeline.rollback"] {
|
||||
assert_eq!(run(&mut s, cmd, json!({"position": 1}))["marker"], 1);
|
||||
assert_eq!(run(&mut s, cmd, json!({"position": "end"}))["marker"], 2);
|
||||
assert_eq!(run(&mut s, cmd, json!({"position": "1"}))["marker"], 1, "a number in a string is still a number");
|
||||
assert_eq!(run(&mut s, cmd, json!({"position": "END"}))["marker"], 2);
|
||||
run(&mut s, cmd, json!({"position": 1}));
|
||||
for bad in [json!("start"), json!("first"), json!([1]), json!({"n": 1}), json!(true), json!("NaN")] {
|
||||
let e = s.execute(cmd, &json!({"position": bad})).unwrap_err().to_string();
|
||||
assert!(e.contains("`position` must be a number") && e.contains("\"end\""), "{bad}: {e}");
|
||||
assert_eq!(s.doc.marker, Some(1), "{bad}: the refused call left the marker where it was");
|
||||
}
|
||||
assert_eq!(run(&mut s, cmd, json!({}))["marker"], 2, "omitted: the end");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn g2_fillet_survives_edit_undo_and_reopen() {
|
||||
use solidcraft_doc::{Document, FeatureKind, FilletStyle};
|
||||
@@ -544,133 +522,3 @@ fn edit_refuses_unknown_fields() {
|
||||
let def = run(&mut s, "timeline.inspect", json!({"feature": "Twin"}))["definition"].clone();
|
||||
assert_eq!(def["combine"], json!(true), "{def}");
|
||||
}
|
||||
|
||||
fn suppressed(s: &Session, name: &str) -> bool {
|
||||
s.doc.find_feature(name).unwrap().suppressed
|
||||
}
|
||||
|
||||
/// timeline.suppress takes `features: [..]` as timeline.delete does, as one undo step; it used to
|
||||
/// refuse it ("`feature` must be an id or name").
|
||||
#[test]
|
||||
fn suppress_takes_a_list_of_features_as_one_undo_step() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "name": "B"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0], "name": "C"}));
|
||||
let id = |s: &Session, name: &str| s.doc.find_feature(name).unwrap().id;
|
||||
let undo = s.undo.len();
|
||||
let a = id(&s, "A");
|
||||
let r = run(&mut s, "timeline.suppress", json!({"features": [a, "B"], "suppressed": true}));
|
||||
assert_eq!(r, json!({"features": [id(&s, "A"), id(&s, "B")], "suppressed": true}));
|
||||
assert!(suppressed(&s, "A") && suppressed(&s, "B") && !suppressed(&s, "C"));
|
||||
assert_eq!(s.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!suppressed(&s, "A") && !suppressed(&s, "B"));
|
||||
assert_eq!(s.model.state().bodies.len(), 3);
|
||||
|
||||
// Toggling several: all suppressed unless all already are, then all unsuppressed.
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "A"}));
|
||||
run(&mut s, "timeline.suppress", json!({"features": ["A", "B", "C"]}));
|
||||
assert!(suppressed(&s, "A") && suppressed(&s, "B") && suppressed(&s, "C"));
|
||||
run(&mut s, "timeline.suppress", json!({"features": ["A", "B", "C"]}));
|
||||
assert!(!suppressed(&s, "A") && !suppressed(&s, "B") && !suppressed(&s, "C"));
|
||||
// `feature` and `features` together; a repeat counts once.
|
||||
let r = run(&mut s, "timeline.suppress", json!({"feature": "C", "features": ["C", "B"], "suppressed": true}));
|
||||
assert_eq!(r["features"], json!([id(&s, "C"), id(&s, "B")]), "{r}");
|
||||
assert!(!suppressed(&s, "A") && suppressed(&s, "B") && suppressed(&s, "C"));
|
||||
// A single `feature` replies as before.
|
||||
let r = run(&mut s, "timeline.suppress", json!({"feature": "C", "suppressed": false}));
|
||||
assert_eq!(r, json!({"feature": id(&s, "C"), "suppressed": false}));
|
||||
|
||||
// An unknown feature in the list refuses the whole command, as do no features at all.
|
||||
let before = (*s.doc).clone();
|
||||
let undo = s.undo.len();
|
||||
for bad in [
|
||||
json!({"features": ["A", "Nope"], "suppressed": true}),
|
||||
json!({"features": []}),
|
||||
json!({}),
|
||||
json!({"features": [{}]}),
|
||||
json!({"features": ["A"], "suppressed": "yes"}),
|
||||
] {
|
||||
assert!(s.execute("timeline.suppress", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(*s.doc, before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
// timeline.delete takes either.
|
||||
run(&mut s, "timeline.delete", json!({"feature": "C"}));
|
||||
run(&mut s, "timeline.delete", json!({"features": ["B"]}));
|
||||
assert!(s.doc.find_feature("B").is_none() && s.doc.find_feature("C").is_none());
|
||||
}
|
||||
|
||||
/// The value at a dotted path of a definition.
|
||||
fn dotted<'a>(def: &'a Value, path: &str) -> &'a Value {
|
||||
path.split('.').fold(def, |v, k| &v[k])
|
||||
}
|
||||
|
||||
/// The lowest Z of the design's first body.
|
||||
fn low_z(s: &mut Session) -> f64 {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
let min = &m["bodies"][0]["bbox"]["min"];
|
||||
min[2].as_f64().or_else(|| min["z"].as_f64()).unwrap_or_else(|| panic!("{m}"))
|
||||
}
|
||||
|
||||
/// timeline.edit takes the dotted keys its unknown-field message lists (`plane.distance`), and
|
||||
/// numbers for expressions, as the feature commands do; both used to be refused.
|
||||
#[test]
|
||||
fn edit_takes_dotted_keys_and_numbers_for_expressions() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 5, "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "name": "Pad"}));
|
||||
let def = |s: &mut Session, f: &str| run(s, "timeline.inspect", json!({"feature": f}))["definition"].clone();
|
||||
|
||||
run(&mut s, "timeline.edit", json!({"feature": "S", "set": {"plane.distance": "8 mm"}}));
|
||||
assert_eq!(def(&mut s, "S")["plane"]["distance"], json!("8 mm"));
|
||||
assert!((low_z(&mut s) - 8.0).abs() < 1e-9);
|
||||
// A bare number is mm, as in sketch.create's `offset`.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "S", "set": {"plane.distance": 12}}));
|
||||
assert_eq!(def(&mut s, "S")["plane"]["distance"], json!("12"));
|
||||
assert!((low_z(&mut s) - 12.0).abs() < 1e-9);
|
||||
run(&mut s, "timeline.edit", json!({"feature": "S", "set": {"plane": {"distance": 3.5}}}));
|
||||
assert_eq!(def(&mut s, "S")["plane"]["distance"], json!("3.5"));
|
||||
// The nested form works as before.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "S", "set": {"plane": {"distance": "4"}}}));
|
||||
assert!((low_z(&mut s) - 4.0).abs() < 1e-9);
|
||||
|
||||
// Every field the unknown-field message lists is accepted as it is written there.
|
||||
let e = s.execute("timeline.edit", &json!({"feature": "S", "set": {"plane.dist": "1"}})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown field `plane.dist`") && e.contains("`plane.distance`"), "{e}");
|
||||
let e = s.execute("timeline.edit", &json!({"feature": "S", "set": {"nope": "1"}})).unwrap_err().to_string();
|
||||
let listed: Vec<&str> = e.split('`').skip(3).step_by(2).collect();
|
||||
assert!(listed.contains(&"plane.distance") && listed.contains(&"plane.type"), "{e}");
|
||||
// Not the sketch geometry, which timeline.inspect leaves out and the sketch commands edit.
|
||||
assert!(!listed.iter().any(|k| k.starts_with("sketch")), "{e}");
|
||||
let now = def(&mut s, "S");
|
||||
for key in listed {
|
||||
let value = dotted(&now, key).clone();
|
||||
run(&mut s, "timeline.edit", json!({"feature": "S", "set": {key: value}}));
|
||||
}
|
||||
assert_eq!(def(&mut s, "S"), now);
|
||||
|
||||
// Numbers on an extrude, also for an expression the definition leaves out.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Pad", "set": {"extent.distance": 20, "extent": {"taper": 0}}}));
|
||||
let d = def(&mut s, "Pad");
|
||||
assert_eq!((&d["extent"]["distance"], &d["extent"]["taper"]), (&json!("20"), &json!("0")), "{d}");
|
||||
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!((volume - 2000.0).abs() < 1e-6, "{volume}");
|
||||
|
||||
// Still refused: a field given twice, unknown dotted fields, a number for a name.
|
||||
let before = (*s.doc).clone();
|
||||
for (set, says) in [
|
||||
(json!({"plane.distance": "1", "plane": {"distance": "2"}}), "given twice"),
|
||||
(json!({"plane.wobble": "1"}), "unknown field `plane.wobble`"),
|
||||
(json!({"plane.distance.x": "1"}), "invalid feature"),
|
||||
(json!({"plane.base.name": 5}), "invalid feature"),
|
||||
] {
|
||||
let e = s.execute("timeline.edit", &json!({"feature": "S", "set": set})).unwrap_err().to_string();
|
||||
assert!(e.contains(says), "{set}: {e}");
|
||||
}
|
||||
assert_eq!(*s.doc, before);
|
||||
}
|
||||
|
||||
@@ -2188,10 +2188,6 @@ mod offset_identity_tests;
|
||||
#[path = "offset_edit_guard_tests.rs"]
|
||||
mod offset_edit_guard_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_ends_tests.rs"]
|
||||
mod offset_ends_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_plane_face_tests.rs"]
|
||||
mod offset_plane_face_tests;
|
||||
|
||||
@@ -24,7 +24,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
|
||||
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's)"),
|
||||
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
@@ -218,20 +218,7 @@ fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "only STEP, IGES, FreeCAD and Rhino files (.step, .stp, .igs, .iges, .fcstd, .3dm) can be inserted"));
|
||||
}
|
||||
let options = step_options(p, cmd)?;
|
||||
let placement = match p.get("matrix") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(m) => Some(super::component::matrix::rigid(m, super::component::matrix::tolerance(p, cmd)?, cmd)?),
|
||||
};
|
||||
let mut out = add_step(s, path, str_(p, "name"), &options)?;
|
||||
if let Some(m) = placement {
|
||||
let occ = out["occurrence"].as_u64().ok_or_else(|| EngineError::Other("the import has no placement to move".into()))?;
|
||||
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == occ) {
|
||||
o.transform = solidcraft_doc::mat_mul(&m, &o.transform);
|
||||
out["transform"] = json!(o.transform);
|
||||
}
|
||||
s.refresh();
|
||||
}
|
||||
Ok(out)
|
||||
add_step(s, path, str_(p, "name"), &options)
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -369,7 +369,6 @@ fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result<Vec<u6
|
||||
offset: 0.0,
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -88,7 +88,7 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !want.is_empty() && bodies.len() != want.len() {
|
||||
return Err(bad("inspect.measure", "unknown body name"));
|
||||
}
|
||||
// Sheet metal keeps the edges where bends meet flat faces (so bends stay visible): count its
|
||||
// Sheet metal keeps the edges where bends meet flat faces (as Fusion does): count its
|
||||
// B-rep faces as they are, without merging coplanar neighbours.
|
||||
let sheets = s.model.state().sheets.clone();
|
||||
for b in &mut bodies {
|
||||
|
||||
@@ -1,439 +0,0 @@
|
||||
//! Interference (`inspect.interference`): the pairs of bodies that share volume, and how much.
|
||||
//!
|
||||
//! Pairs are pruned in three steps before anything exact runs. Their boxes first (sort and
|
||||
//! sweep), then their display meshes: a mesh lies within its chord tolerance of the faces, so
|
||||
//! two meshes farther apart than a margin over both tolerances belong to bodies whose boundaries
|
||||
//! don't meet, and unless one body sits inside the other (a point of each face of one tested
|
||||
//! against the other's mesh) the pair is clear without a boolean. The pairs left are intersected
|
||||
//! exactly and measured, in parallel on desktop (one after another in the browser), the slowest
|
||||
//! first, under a time budget: pairs not finished when it runs out are listed as not checked
|
||||
//! rather than holding the call. The shared part is measured as `solidcraft_kernel::measure`
|
||||
//! measures it (its mass properties alone).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::mesh_distance::TriangleSet;
|
||||
use solidcraft_geom::{Aabb3, Mesh, Vec3};
|
||||
use solidcraft_kernel::Body;
|
||||
use solidcraft_kernel::profile::KernelTimes;
|
||||
|
||||
use crate::params::{bad, num, string_list};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Most pairs with overlapping bounding boxes one call intersects.
|
||||
pub(crate) const MAX_INTERFERENCE_PAIRS: usize = 20_000;
|
||||
/// The time budget when the call gives none (s): under the 300 s an MCP client waits for a tool.
|
||||
pub(crate) const DEFAULT_BUDGET_S: f64 = 240.0;
|
||||
/// The longest time budget a call may ask for (s).
|
||||
const MAX_BUDGET_S: f64 = 86_400.0;
|
||||
/// How many chord tolerances apart (of both meshes together) two meshes must be to clear a pair
|
||||
/// without intersecting it: twice what the tolerances allow, for meshers that sag a little more.
|
||||
const MESH_MARGIN: f64 = 4.0;
|
||||
/// Most pairs listed under `slowest`.
|
||||
const SLOWEST: usize = 8;
|
||||
|
||||
/// One pair to intersect exactly: the bodies (cheap clones sharing the solids) and their names.
|
||||
struct Job {
|
||||
pair: usize,
|
||||
a: Body,
|
||||
b: Body,
|
||||
names: (String, String),
|
||||
/// Faces of one times faces of the other: the slowest pairs start first.
|
||||
cost: usize,
|
||||
}
|
||||
|
||||
/// What an exact check of one pair came to.
|
||||
enum Outcome {
|
||||
Hit(Value),
|
||||
Clear,
|
||||
Unchecked(String),
|
||||
}
|
||||
|
||||
/// One pair's answer and where its time went.
|
||||
struct Done {
|
||||
outcome: Outcome,
|
||||
/// Unverified boolean results are listed as unchecked and still reported as a hit.
|
||||
note: Option<String>,
|
||||
ms: f64,
|
||||
kernel: KernelTimes,
|
||||
}
|
||||
|
||||
pub(super) fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.interference";
|
||||
let started = web_time::Instant::now();
|
||||
let budget_s = match p.get("budget_s") {
|
||||
None | Some(Value::Null) => DEFAULT_BUDGET_S,
|
||||
Some(_) => match num(p, "budget_s") {
|
||||
Some(v) if v > 0.0 && v <= MAX_BUDGET_S => v,
|
||||
_ => return Err(bad(cmd, format!("`budget_s` must be a number of seconds above 0, at most {MAX_BUDGET_S}"))),
|
||||
},
|
||||
};
|
||||
let budget = std::time::Duration::from_secs_f64(budget_s);
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.world_state();
|
||||
let bodies: Vec<&solidcraft_doc::ModelBody> =
|
||||
st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).filter(|b| !b.body.is_mesh()).collect();
|
||||
|
||||
// The bodies' display meshes, made once (in parallel) and kept on the bodies: a design open
|
||||
// in the app has them already.
|
||||
let meshes: Vec<Arc<Mesh>> = {
|
||||
let made = solidcraft_kernel::map_with_local_pools(&bodies, usize::MAX, |b| Ok(b.mesh()));
|
||||
made.into_iter().map(|m| m.unwrap_or_default()).collect()
|
||||
};
|
||||
// Prune by bounding boxes first (sort and sweep), so the cost follows the pairs that can
|
||||
// touch rather than the square of the body count.
|
||||
// Each mesh lies within its chord of the faces, inside a round one: grown by that, the boxes
|
||||
// of bodies a hundredth of a millimetre into each other still meet (an arm's round end into a
|
||||
// rounded rim, its facets clear of the rim's).
|
||||
let chords: Vec<f64> = bodies.iter().map(|b| b.display_tol()).collect();
|
||||
let boxes: Vec<Aabb3> = meshes
|
||||
.iter()
|
||||
.zip(&chords)
|
||||
.map(|(m, &chord)| {
|
||||
let (bx, g) = (m.bounds(), 2.0 * chord);
|
||||
if bx.is_empty() { bx } else { Aabb3 { min: bx.min - Vec3::new(g, g, g), max: bx.max + Vec3::new(g, g, g) } }
|
||||
})
|
||||
.collect();
|
||||
let (pairs, skipped) = super::joints::overlapping_pairs(&boxes, MAX_INTERFERENCE_PAIRS);
|
||||
|
||||
// Then by the meshes: pairs whose meshes stay apart (and neither holds the other) are clear.
|
||||
let used: Vec<bool> = {
|
||||
let mut u = vec![false; bodies.len()];
|
||||
for &(i, j) in &pairs {
|
||||
for k in [i, j] {
|
||||
if let Some(x) = u.get_mut(k) {
|
||||
*x = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
u
|
||||
};
|
||||
let sets: Vec<Option<TriangleSet>> = {
|
||||
let idx: Vec<usize> = (0..bodies.len()).filter(|&i| used.get(i).copied().unwrap_or(false)).collect();
|
||||
let made = solidcraft_kernel::map_with_local_pools(&idx, usize::MAX, |&i| Ok(meshes.get(i).map(|m| TriangleSet::new(m))));
|
||||
let mut out: Vec<Option<TriangleSet>> = std::iter::repeat_with(|| None).take(bodies.len()).collect();
|
||||
for (&i, set) in idx.iter().zip(made) {
|
||||
if let (Some(slot), Ok(set)) = (out.get_mut(i), set) {
|
||||
*slot = set;
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
let need_exact = solidcraft_kernel::map_with_local_pools(&pairs, usize::MAX, |&(i, j)| {
|
||||
let (Some(Some(sa)), Some(Some(sb)), Some(ma), Some(mb)) = (sets.get(i), sets.get(j), meshes.get(i), meshes.get(j)) else {
|
||||
return Ok(true);
|
||||
};
|
||||
let margin = MESH_MARGIN * (chords.get(i).copied().unwrap_or(0.0) + chords.get(j).copied().unwrap_or(0.0));
|
||||
Ok(!meshes_apart(ma, sa, mb, sb, margin))
|
||||
});
|
||||
let mut jobs = Vec::new();
|
||||
let mut cleared = 0usize;
|
||||
for (k, (&(i, j), exact)) in pairs.iter().zip(need_exact).enumerate() {
|
||||
if !exact.unwrap_or(true) {
|
||||
cleared += 1;
|
||||
continue;
|
||||
}
|
||||
let (Some(a), Some(b)) = (bodies.get(i), bodies.get(j)) else { continue };
|
||||
jobs.push(Job {
|
||||
pair: k,
|
||||
a: a.body.clone(),
|
||||
b: b.body.clone(),
|
||||
names: (a.name.clone(), b.name.clone()),
|
||||
cost: a.body.face_count().saturating_mul(b.body.face_count()),
|
||||
});
|
||||
}
|
||||
let intersected = jobs.len();
|
||||
jobs.sort_by(|x, y| y.cost.cmp(&x.cost).then(x.pair.cmp(&y.pair)));
|
||||
|
||||
let left = budget.saturating_sub(started.elapsed());
|
||||
let waited = web_time::Instant::now();
|
||||
let (done, threads) = run_jobs(jobs, left);
|
||||
let waited_ms = waited.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let (mut hits, mut unchecked, mut timed) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let (mut not_checked, mut boolean_ms, mut tessellate_ms) = (0usize, 0.0, 0.0);
|
||||
for ((a, b), d) in done {
|
||||
let Some(d) = d else {
|
||||
not_checked += 1;
|
||||
unchecked.push(json!({"a": a, "b": b, "reason": format!("not checked: the time budget of {budget_s} s ran out (budget_s)")}));
|
||||
continue;
|
||||
};
|
||||
boolean_ms += d.kernel.boolean_ms;
|
||||
tessellate_ms += d.kernel.tessellate_ms;
|
||||
timed.push((
|
||||
d.ms,
|
||||
json!({"a": a, "b": b, "ms": round_ms(d.ms), "boolean_ms": round_ms(d.kernel.boolean_ms),
|
||||
"tessellate_ms": round_ms(d.kernel.tessellate_ms)}),
|
||||
));
|
||||
if let Some(reason) = d.note {
|
||||
unchecked.push(json!({"a": a, "b": b, "reason": reason}));
|
||||
}
|
||||
match d.outcome {
|
||||
Outcome::Hit(v) => hits.push(v),
|
||||
Outcome::Clear => {}
|
||||
Outcome::Unchecked(reason) => unchecked.push(json!({"a": a, "b": b, "reason": reason})),
|
||||
}
|
||||
}
|
||||
// A timing profile of the command (`--profile`) counts the kernel time the workers spent,
|
||||
// shared out over the time this thread waited for them (on the calling thread it is counted
|
||||
// already).
|
||||
if threads > 0 && boolean_ms + tessellate_ms > 0.0 {
|
||||
let share = (waited_ms / (boolean_ms + tessellate_ms)).min(1.0);
|
||||
solidcraft_kernel::profile::credit(KernelTimes { boolean_ms: boolean_ms * share, tessellate_ms: tessellate_ms * share });
|
||||
}
|
||||
timed.sort_by(|x, y| y.0.total_cmp(&x.0));
|
||||
let slowest: Vec<Value> = timed.into_iter().take(SLOWEST).map(|t| t.1).collect();
|
||||
Ok(json!({
|
||||
"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked,
|
||||
"truncated": skipped > 0, "skipped_pairs": skipped,
|
||||
"pairs_candidate": pairs.len(), "pairs_cleared": cleared, "pairs_intersected": intersected,
|
||||
"pairs_not_checked": not_checked, "budget_s": budget_s, "budget_exceeded": not_checked > 0,
|
||||
"threads": threads, "boolean_ms": round_ms(boolean_ms), "tessellate_ms": round_ms(tessellate_ms),
|
||||
"slowest": slowest, "ms": round_ms(started.elapsed().as_secs_f64() * 1000.0),
|
||||
}))
|
||||
}
|
||||
|
||||
fn round_ms(ms: f64) -> f64 {
|
||||
(ms * 1000.0).round() / 1000.0
|
||||
}
|
||||
|
||||
/// Are two bodies' meshes farther apart than `margin` with neither body inside the other? Then
|
||||
/// their boundaries don't meet (each mesh is within its chord tolerance of its faces, which the
|
||||
/// margin covers), so the bodies share no volume. Conservative: an empty mesh, nested boxes or
|
||||
/// a point of one inside the other answer no.
|
||||
fn meshes_apart(ma: &Mesh, sa: &TriangleSet, mb: &Mesh, sb: &TriangleSet, margin: f64) -> bool {
|
||||
if ma.triangles.is_empty() || mb.triangles.is_empty() || !margin.is_finite() {
|
||||
return false;
|
||||
}
|
||||
if sa.closest(sb, margin).is_some() {
|
||||
return false;
|
||||
}
|
||||
// Apart boundaries: either the bodies are apart, or one holds the other (or a piece of it).
|
||||
let (ba, bb) = (ma.bounds(), mb.bounds());
|
||||
if nested(&ba, &bb) || nested(&bb, &ba) {
|
||||
return false;
|
||||
}
|
||||
!(any_inside(&face_points(ma), mb) || any_inside(&face_points(mb), ma))
|
||||
}
|
||||
|
||||
/// Is `inner` inside `outer`?
|
||||
fn nested(inner: &Aabb3, outer: &Aabb3) -> bool {
|
||||
!inner.is_empty()
|
||||
&& outer.min.x <= inner.min.x
|
||||
&& outer.min.y <= inner.min.y
|
||||
&& outer.min.z <= inner.min.z
|
||||
&& inner.max.x <= outer.max.x
|
||||
&& inner.max.y <= outer.max.y
|
||||
&& inner.max.z <= outer.max.z
|
||||
}
|
||||
|
||||
/// A corner of each face's first triangle (every piece of a body in several pieces has a face).
|
||||
fn face_points(m: &Mesh) -> Vec<Vec3> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for (k, t) in m.triangles.iter().enumerate() {
|
||||
let face = m.tri_face.get(k).copied().unwrap_or(0);
|
||||
if seen.insert(face)
|
||||
&& let Some(p) = m.positions.get(t[0] as usize)
|
||||
{
|
||||
out.push(*p);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Is any of `points` inside the closed mesh `outer`?
|
||||
fn any_inside(points: &[Vec3], outer: &Mesh) -> bool {
|
||||
let b = outer.bounds();
|
||||
let within = |p: &&Vec3| b.min.x <= p.x && p.x <= b.max.x && b.min.y <= p.y && p.y <= b.max.y && b.min.z <= p.z && p.z <= b.max.z;
|
||||
let candidates: Vec<&Vec3> = points.iter().filter(within).collect();
|
||||
match candidates.len() {
|
||||
0 => false,
|
||||
1..=4 => candidates.iter().any(|p| outer.contains(**p)),
|
||||
_ => {
|
||||
let index = outer.inside_index();
|
||||
candidates.iter().any(|p| index.contains(**p))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact check of one pair: a pin a hair off the axis of its hole by the walls' crescent,
|
||||
/// otherwise the intersection measured.
|
||||
fn check(job: &Job) -> Done {
|
||||
let started = web_time::Instant::now();
|
||||
let ((outcome, note), kernel) = solidcraft_kernel::profile::capture(|| check_pair(&job.a, &job.b, &job.names));
|
||||
Done { outcome, note, ms: started.elapsed().as_secs_f64() * 1000.0, kernel }
|
||||
}
|
||||
|
||||
fn check_pair(a: &Body, b: &Body, (na, nb): &(String, String)) -> (Outcome, Option<String>) {
|
||||
let threshold = 1e-9 * a.size().max(1.0).powi(3);
|
||||
// A pin a hair off the axis of its hole: the crescent the walls share, which the boolean
|
||||
// can't intersect.
|
||||
if let Some(o) = solidcraft_kernel::coaxial_overlap(a, b) {
|
||||
if o.volume > threshold {
|
||||
let hit = json!({"a": na, "b": nb, "volume_mm3": o.volume, "area_mm2": o.area, "center": o.centroid,
|
||||
"bbox": {"min": o.bbox.min, "max": o.bbox.max}, "near_coaxial": {"offset_mm": o.offset, "length_mm": o.length}});
|
||||
return (Outcome::Hit(hit), None);
|
||||
}
|
||||
return (Outcome::Clear, None);
|
||||
}
|
||||
// A pair whose shared part can't be worked out is reported, not passed as clear.
|
||||
let (shared, report) = solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(a, b, solidcraft_kernel::BoolOp::Intersect));
|
||||
let shared = match shared {
|
||||
Ok(o) => o,
|
||||
Err(e) => return (Outcome::Unchecked(e.to_string()), None),
|
||||
};
|
||||
let mut note = report.unverified();
|
||||
let pieces = match shared {
|
||||
Some(o) => vec![o],
|
||||
// A body in several pieces, one of them wholly inside the other body: the whole bodies'
|
||||
// boolean can come out empty, so the pieces are intersected one by one.
|
||||
None if a.shell_count() > 1 || b.shell_count() > 1 => match by_pieces(a, b) {
|
||||
Ok((pieces, unverified)) => {
|
||||
note = note.or(unverified);
|
||||
pieces
|
||||
}
|
||||
Err(e) => return (Outcome::Unchecked(e), note),
|
||||
},
|
||||
None => return (Outcome::Clear, note),
|
||||
};
|
||||
// The shared parts measured together: volumes and areas add, the centre is the volumes'.
|
||||
let mut measured = Vec::new();
|
||||
for o in &pieces {
|
||||
match solidcraft_kernel::measure_mass(o) {
|
||||
Ok(m) => measured.push(m),
|
||||
Err(e) => return (Outcome::Unchecked(e.to_string()), note),
|
||||
}
|
||||
}
|
||||
let volume: f64 = measured.iter().map(|m| m.volume).sum();
|
||||
if volume > threshold {
|
||||
let area: f64 = measured.iter().map(|m| m.area).sum();
|
||||
let bbox = measured.iter().fold(Aabb3::EMPTY, |b, m| b.union(&m.bbox));
|
||||
let center = match measured.as_slice() {
|
||||
[m] => m.centroid,
|
||||
_ => measured.iter().fold(Vec3::ZERO, |c, m| c + m.centroid * m.volume) / volume,
|
||||
};
|
||||
let hit = json!({"a": na, "b": nb, "volume_mm3": volume, "area_mm2": area, "center": center, "bbox": {"min": bbox.min, "max": bbox.max}});
|
||||
return (Outcome::Hit(hit), note);
|
||||
}
|
||||
(Outcome::Clear, note)
|
||||
}
|
||||
|
||||
/// Most pairs of pieces [`by_pieces`] intersects.
|
||||
const MAX_PIECE_PAIRS: usize = 256;
|
||||
|
||||
/// The shared parts of two bodies, a piece of each at a time, and why any of them is unverified.
|
||||
fn by_pieces(a: &Body, b: &Body) -> std::result::Result<(Vec<Body>, Option<String>), String> {
|
||||
let (la, lb) = (a.lumps().map_err(|e| e.to_string())?, b.lumps().map_err(|e| e.to_string())?);
|
||||
if la.len() < 2 && lb.len() < 2 {
|
||||
return Ok((Vec::new(), None));
|
||||
}
|
||||
if la.len().saturating_mul(lb.len()) > MAX_PIECE_PAIRS {
|
||||
return Err(format!("not checked: {} by {} pieces are more than {MAX_PIECE_PAIRS} pairs of pieces", la.len(), lb.len()));
|
||||
}
|
||||
let (mut out, mut note) = (Vec::new(), None);
|
||||
for x in &la {
|
||||
for y in &lb {
|
||||
let (r, report) =
|
||||
solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(x, y, solidcraft_kernel::BoolOp::Intersect));
|
||||
note = note.or(report.unverified());
|
||||
if let Some(o) = r.map_err(|e| e.to_string())? {
|
||||
out.push(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((out, note))
|
||||
}
|
||||
|
||||
/// Each job's answer (in pair order, with the pair's names; `None`: not finished within
|
||||
/// `budget`) and how many worker threads ran them (0: this thread did).
|
||||
fn run_jobs(jobs: Vec<Job>, budget: std::time::Duration) -> (Vec<((String, String), Option<Done>)>, usize) {
|
||||
let mut order: Vec<(usize, usize, (String, String))> = jobs.iter().enumerate().map(|(k, j)| (j.pair, k, j.names.clone())).collect();
|
||||
order.sort_unstable_by_key(|o| o.0);
|
||||
let (mut results, threads) = run_ordered(jobs, budget);
|
||||
let out = order.into_iter().map(|(_, k, names)| (names, results.get_mut(k).and_then(Option::take))).collect();
|
||||
(out, threads)
|
||||
}
|
||||
|
||||
/// Each job's answer in job order, and the threads used. On desktop the jobs run on worker
|
||||
/// threads of their own, which the call does not wait for once `budget` is spent: a pair still
|
||||
/// running then finishes in the background and is dropped (a kernel boolean can't be stopped
|
||||
/// part way).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn run_ordered(jobs: Vec<Job>, budget: std::time::Duration) -> (Vec<Option<Done>>, usize) {
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc;
|
||||
|
||||
let n = jobs.len();
|
||||
let mut out: Vec<Option<Done>> = std::iter::repeat_with(|| None).take(n).collect();
|
||||
if n == 0 || budget.is_zero() {
|
||||
return (out, 0);
|
||||
}
|
||||
let deadline = web_time::Instant::now() + budget;
|
||||
// The workers outlive the call when the budget runs out, so they own what they work on.
|
||||
let jobs = Arc::new(jobs);
|
||||
let next = Arc::new(AtomicUsize::new(0));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (tx, rx) = mpsc::channel::<(usize, Done)>();
|
||||
let want = std::thread::available_parallelism().map(usize::from).unwrap_or(1).min(n).max(1);
|
||||
let mut threads = 0;
|
||||
for _ in 0..want {
|
||||
let (jobs, next, stop, tx) = (jobs.clone(), next.clone(), stop.clone(), tx.clone());
|
||||
let spawned = std::thread::Builder::new().name("solidcraft-interference".into()).spawn(move || {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let k = next.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(job) = jobs.get(k) else { break };
|
||||
let done = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check(job))).unwrap_or_else(|_| Done {
|
||||
outcome: Outcome::Unchecked("internal error while checking this pair".into()),
|
||||
note: None,
|
||||
ms: 0.0,
|
||||
kernel: KernelTimes::default(),
|
||||
});
|
||||
if tx.send((k, done)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
threads += 1;
|
||||
}
|
||||
}
|
||||
drop(tx);
|
||||
if threads == 0 {
|
||||
// No thread could be started: one pair after another here.
|
||||
return (run_here(&jobs, deadline), 0);
|
||||
}
|
||||
let mut received = 0;
|
||||
while received < n {
|
||||
let left = deadline.saturating_duration_since(web_time::Instant::now());
|
||||
match rx.recv_timeout(left) {
|
||||
Ok((k, done)) => {
|
||||
if let Some(slot) = out.get_mut(k) {
|
||||
*slot = Some(done);
|
||||
}
|
||||
received += 1;
|
||||
}
|
||||
// Out of time, or every worker gone (none is left with work then).
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
(out, threads)
|
||||
}
|
||||
|
||||
/// The browser has no threads: one pair after another, none started once the budget is spent.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn run_ordered(jobs: Vec<Job>, budget: std::time::Duration) -> (Vec<Option<Done>>, usize) {
|
||||
(run_here(&jobs, web_time::Instant::now() + budget), 0)
|
||||
}
|
||||
|
||||
/// The jobs one after another on this thread, none started past `deadline`.
|
||||
fn run_here(jobs: &[Job], deadline: web_time::Instant) -> Vec<Option<Done>> {
|
||||
jobs.iter().map(|job| (web_time::Instant::now() < deadline).then(|| check(job))).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "interference_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,110 +0,0 @@
|
||||
//! `inspect.interference`: the mesh pre-filter, the bodies it must not clear, and the budget.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn f(v: &Value) -> f64 {
|
||||
v.as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// Curved bodies whose boxes overlap but which stay apart are answered from their meshes: no
|
||||
/// boolean runs. Two cylinders on a diagonal (centres 12.7 mm apart, radii 5), and a torus
|
||||
/// beside a sphere that sits in the corner of its box.
|
||||
#[test]
|
||||
fn separated_curved_bodies_are_cleared_without_a_boolean() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "base": [0, 0, 0], "body_name": "C1"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "base": [9, 9, 0], "operation": "new", "body_name": "C2"}));
|
||||
run(&mut s, "solid.torus", json!({"major": 20, "minor": 3, "center": [60, 0, 0], "body_name": "Torus"}));
|
||||
// In the torus box's corner (the box reaches 23 out on each axis), 1.66 mm clear of the tube.
|
||||
run(&mut s, "solid.sphere", json!({"radius": 3, "center": [80, 19, 1], "operation": "new", "body_name": "Ball"}));
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["count"], 0, "{i}");
|
||||
assert_eq!(i["pairs_candidate"], 2, "{i}");
|
||||
assert_eq!(i["pairs_cleared"], 2, "{i}");
|
||||
assert_eq!(i["pairs_intersected"], 0, "{i}");
|
||||
assert_eq!(f(&i["boolean_ms"]), 0.0, "{i}");
|
||||
assert_eq!(i["unchecked"].as_array().map(Vec::len), Some(0), "{i}");
|
||||
// Moved into each other, the same pairs are intersected and overlap.
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "base": [7, 0, 0], "operation": "new", "body_name": "C3"}));
|
||||
let i = run(&mut s, "inspect.interference", json!({"bodies": ["C1", "C3"]}));
|
||||
assert_eq!(i["count"], 1, "{i}");
|
||||
assert_eq!(i["pairs_intersected"], 1, "{i}");
|
||||
// The lens of two r5 circles 7 apart, 10 high.
|
||||
let (r, d): (f64, f64) = (5.0, 7.0);
|
||||
let lens = 2.0 * r * r * (d / (2.0 * r)).acos() - d / 2.0 * (4.0 * r * r - d * d).sqrt();
|
||||
let got = f(&i["interferences"][0]["volume_mm3"]);
|
||||
assert!((got - 10.0 * lens).abs() < 1e-3 * 10.0 * lens, "{got} against {}: {i}", 10.0 * lens);
|
||||
}
|
||||
|
||||
/// A body wholly inside another has meshes that never meet: it is still intersected, and its
|
||||
/// whole volume is the overlap.
|
||||
#[test]
|
||||
fn a_body_inside_another_is_still_found() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 40, "corner": [-20, -20, -20], "body_name": "Block"}));
|
||||
run(&mut s, "solid.sphere", json!({"radius": 5, "center": [3, -2, 1], "operation": "new", "body_name": "Ball"}));
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["count"], 1, "{i}");
|
||||
assert_eq!(i["pairs_cleared"], 0, "{i}");
|
||||
let want = 4.0 / 3.0 * std::f64::consts::PI * 125.0;
|
||||
let got = f(&i["interferences"][0]["volume_mm3"]);
|
||||
assert!((got - want).abs() < 1e-3 * want, "{got} against {want}: {i}");
|
||||
}
|
||||
|
||||
/// A body in two pieces, one of them wholly inside a block and the other outside it: the boxes
|
||||
/// are not nested and the meshes never meet, but a point of the inner piece's faces is inside
|
||||
/// the block, so the pair is intersected. The whole bodies' boolean comes out empty there, so
|
||||
/// the pieces are intersected one by one.
|
||||
#[test]
|
||||
fn a_piece_of_a_body_inside_another_is_still_found() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [0, 0, 0], "body_name": "Block"}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [5, 5, 5], "operation": "new", "body_name": "Pieces"}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [30, 5, 5], "operation": "join", "targets": ["Pieces"]}));
|
||||
let st = s.world_state();
|
||||
let pieces = st.bodies.iter().find(|b| b.name == "Pieces").map(|b| b.body.shell_count());
|
||||
assert_eq!(pieces, Some(2), "the two cubes are one body in two pieces");
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["count"], 1, "{i}");
|
||||
assert_eq!(i["pairs_cleared"], 0, "{i}");
|
||||
assert!((f(&i["interferences"][0]["volume_mm3"]) - 8.0).abs() < 1e-6, "{i}");
|
||||
}
|
||||
|
||||
/// A budget spent before any pair could start lists every pair as not checked (and the call
|
||||
/// returns); bad budgets are refused.
|
||||
#[test]
|
||||
fn pairs_left_when_the_budget_runs_out_are_listed_not_checked() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 0, 0], "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [5, 0, 0], "operation": "new", "body_name": "B"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [12, 0, 0], "operation": "new", "body_name": "C"}));
|
||||
let i = run(&mut s, "inspect.interference", json!({"budget_s": 1e-12}));
|
||||
assert_eq!(i["count"], 0, "{i}");
|
||||
assert_eq!(i["budget_exceeded"], true, "{i}");
|
||||
assert_eq!(i["pairs_not_checked"], 2, "{i}");
|
||||
let unchecked = i["unchecked"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(unchecked.len(), 2, "{i}");
|
||||
assert!(unchecked.iter().all(|u| u["reason"].as_str().is_some_and(|r| r.starts_with("not checked") && r.contains("time budget"))), "{i}");
|
||||
// The default budget checks both pairs: 500 and 300 mm³.
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["budget_exceeded"], false, "{i}");
|
||||
assert_eq!(i["budget_s"], 240.0, "{i}");
|
||||
assert_eq!(i["count"], 2, "{i}");
|
||||
let mut v: Vec<f64> = i["interferences"].as_array().into_iter().flatten().map(|h| f(&h["volume_mm3"])).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
assert!((v[0] - 300.0).abs() < 1e-6 && (v[1] - 500.0).abs() < 1e-6, "{i}");
|
||||
// Answers come in pair order whatever order they finished in.
|
||||
assert_eq!((i["interferences"][0]["a"].as_str(), i["interferences"][0]["b"].as_str()), (Some("A"), Some("B")), "{i}");
|
||||
for bad in [json!(0), json!(-1), json!("soon"), json!(f64::MAX), json!(1e6)] {
|
||||
assert!(s.execute("inspect.interference", &json!({"budget_s": bad})).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
//! Joint origins on an edge of a face (its middle, or a corner) follow the face as it changes size.
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::tests::{place, run};
|
||||
use crate::Session;
|
||||
|
||||
/// A 2 mm panel `W` x 300 (W = 400) in component Panel, and a 20 x 10 x 6 bracket in component
|
||||
/// Bracket, off to the side.
|
||||
fn panel() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "W", "expression": "400 mm"}));
|
||||
run(&mut s, "component.create", json!({"name": "Panel"}));
|
||||
run(&mut s, "solid.box", json!({"length": "W", "width": 300, "height": 2, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Bracket"}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 6, "corner": [0, 500, 0]}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The middle of the bracket's bottom face, where it is now.
|
||||
fn bracket(s: &Session) -> Vec3 {
|
||||
place(s, "Bracket", Vec3::new(10.0, 505.0, 0.0))
|
||||
}
|
||||
|
||||
fn near(a: Vec3, b: Vec3) -> bool {
|
||||
a.dist(b) < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_set_in_from_a_corner_stays_there_as_the_panel_grows() {
|
||||
let mut s = panel();
|
||||
// The front right corner of the top face; the bracket 40 mm in along both edges.
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "planar", "a": {"occurrence": "Panel", "edge": [200, 0, 2], "corner": [400, 0, 2]},
|
||||
"b": {"occurrence": "Bracket", "face": [10, 505, 0]}, "values": [40, 40, 0], "name": "Corner"}),
|
||||
);
|
||||
for w in [400.0, 600.0, 250.0, 1000.0] {
|
||||
run(&mut s, "parameters.change", json!({"name": "W", "expression": format!("{w} mm")}));
|
||||
assert!(near(bracket(&s), Vec3::new(w - 40.0, 40.0, 2.0)), "W {w}: {:?}", bracket(&s));
|
||||
assert_eq!(run(&mut s, "joint.solve", json!({}))["errors"], json!([]));
|
||||
}
|
||||
let snap = s.doc.assembly.joints.first().map(|j| j.a.snap.clone());
|
||||
assert!(
|
||||
matches!(&snap, Some(solidcraft_doc::joints::Snap::Edge { edge: Some(_), face: Some(_), corner: Some(_), .. })),
|
||||
"the edge, the face and the corner's other edge are kept by name: {snap:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_at_an_edges_middle_follows_it_and_the_face_can_be_chosen() {
|
||||
let mut s = panel();
|
||||
// The right edge of the top face: its middle is (W, 150, 2), y points into the face (−X).
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "planar", "a": {"occurrence": "Panel", "edge": [400, 100, 2]},
|
||||
"b": {"occurrence": "Bracket", "face": [10, 505, 0]}, "values": [0, 40, 0], "name": "Mid"}),
|
||||
);
|
||||
for w in [400.0, 700.0, 320.0] {
|
||||
run(&mut s, "parameters.change", json!({"name": "W", "expression": format!("{w} mm")}));
|
||||
assert!(near(bracket(&s), Vec3::new(w - 40.0, 150.0, 2.0)), "W {w}: {:?}", bracket(&s));
|
||||
}
|
||||
// The same edge on the end face: z points out of the end (+X), y into it (down).
|
||||
run(&mut s, "joint.delete", json!({"joint": "Mid"}));
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "edge": [320, 100, 2], "on": [320, 150, 1]},
|
||||
"b": {"occurrence": "Bracket", "face": [10, 505, 0]}, "name": "End"}),
|
||||
);
|
||||
assert!(near(bracket(&s), Vec3::new(320.0, 150.0, 2.0)), "{:?}", bracket(&s));
|
||||
// The bracket's bottom faces the end face: its top (6 mm up its own z) is out along +X.
|
||||
assert!(
|
||||
near(place(&s, "Bracket", Vec3::new(10.0, 505.0, 6.0)), Vec3::new(326.0, 150.0, 2.0)),
|
||||
"{:?}",
|
||||
place(&s, "Bracket", Vec3::new(10.0, 505.0, 6.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_origins_refuse_bad_picks_and_report_a_lost_edge() {
|
||||
let mut s = panel();
|
||||
let bad = |s: &mut Session, a: serde_json::Value| {
|
||||
s.execute("joint.create", &json!({"type": "rigid", "a": a, "b": {"occurrence": "Bracket", "face": [10, 505, 0]}})).unwrap_err().to_string()
|
||||
};
|
||||
// Not on an edge; a face the edge doesn't bound.
|
||||
assert!(bad(&mut s, json!({"occurrence": "Panel", "edge": [200, 150, 2]})).contains("no edge there"));
|
||||
assert!(bad(&mut s, json!({"occurrence": "Panel", "edge": [200, 0, 2], "on": [200, 150, 0]})).contains("doesn't bound"));
|
||||
// A design whose edge name no longer finds anything: the joint is left out with the reason.
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "edge": [200, 0, 2]}, "b": {"occurrence": "Bracket", "face": [10, 505, 0]}, "name": "J"}),
|
||||
);
|
||||
let mut doc = (*s.doc).clone();
|
||||
if let Some(j) = doc.assembly.joints.first_mut()
|
||||
&& let solidcraft_doc::joints::Snap::Edge { edge, .. } = &mut j.a.snap
|
||||
{
|
||||
*edge = Some("F999:nothing|F998:nowhere".into());
|
||||
}
|
||||
*s.doc_mut() = doc;
|
||||
let r = run(&mut s, "joint.solve", json!({}));
|
||||
assert!(r["errors"][0]["why"].as_str().is_some_and(|w| w.contains("no longer exists")), "{r}");
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//! A joint origin on a face or a hole keeps its turn about z as the geometry tilts.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::tests::{occ, run};
|
||||
use crate::Session;
|
||||
|
||||
/// A 40 x 40 x 2 plate in component Panel, turned about Y by the `tilt` parameter (a Move feature),
|
||||
/// and a 10 x 4 x 4 bar in component Bar, long along its X.
|
||||
fn tilted(tilt: f64) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "tilt", "expression": format!("{tilt} deg")}));
|
||||
run(&mut s, "component.create", json!({"name": "Panel"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 2, "corner": [-20, -20, 0]}));
|
||||
let plate = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap_or_default();
|
||||
run(&mut s, "solid.move", json!({"bodies": [plate], "axis": [0, 1, 0], "angle": "tilt"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Bar"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 4, "height": 4, "corner": [100, 0, 0]}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A point on the plate's top face (the largest face facing up).
|
||||
fn top(s: &mut Session) -> Value {
|
||||
let body = s.model.state().bodies.first().map(|b| b.name.clone()).unwrap_or_default();
|
||||
let faces = run(s, "model.faces", json!({"body": body}));
|
||||
let best = faces["faces"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|f| f["plane_normal"][2].as_f64().is_some_and(|z| z > 0.3))
|
||||
.max_by(|a, b| a["area"].as_f64().unwrap_or(0.0).total_cmp(&b["area"].as_f64().unwrap_or(0.0)))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
best["point"].clone()
|
||||
}
|
||||
|
||||
/// How far the bar's long axis is turned from Y, the plate's axis of tilt (degrees, 0…90).
|
||||
fn bar_from_tilt_axis(s: &Session) -> f64 {
|
||||
let m = solidcraft_doc::joints::occurrence_world(&s.doc, occ(s, "Bar"));
|
||||
let x = solidcraft_doc::apply_vector(&m, Vec3::X);
|
||||
x.dot(Vec3::Y).abs().min(1.0).acos().to_degrees()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_on_a_tilting_face_keeps_its_turn_where_the_face_normal_crosses_0_9_along_x() {
|
||||
// The plate starts at 60°: its normal is 0.87 along X. Past 64.2° it is over 0.9, where a
|
||||
// frame worked out from the normal alone picks its x another way and the bar turned a
|
||||
// right angle on the face.
|
||||
let mut s = tilted(60.0);
|
||||
let a = top(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "face": a}, "b": {"occurrence": "Bar", "face": [105, 2, 0]}, "name": "Seat"}),
|
||||
);
|
||||
let start = bar_from_tilt_axis(&s);
|
||||
for tilt in [62.0, 64.0, 66.0, 70.0, 80.0, 50.0, 30.0] {
|
||||
run(&mut s, "parameters.change", json!({"name": "tilt", "expression": format!("{tilt} deg")}));
|
||||
let now = bar_from_tilt_axis(&s);
|
||||
assert!((now - start).abs() < 1e-6, "at {tilt}°: the bar is {now:.3}° from the tilt axis, {start:.3}° at first");
|
||||
let r = run(&mut s, "joint.solve", json!({}));
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
}
|
||||
// The origin keeps that x in the design.
|
||||
let snap = s.doc.assembly.joints.first().map(|j| j.a.snap.clone());
|
||||
assert!(snap.as_ref().and_then(solidcraft_doc::joints::Snap::frame_x).is_some(), "{snap:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_origin_saved_without_a_kept_x_is_placed_as_before() {
|
||||
let mut s = tilted(30.0);
|
||||
let a = top(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "face": a}, "b": {"occurrence": "Bar", "face": [105, 2, 0]}, "name": "Seat"}),
|
||||
);
|
||||
let kept = bar_from_tilt_axis(&s);
|
||||
// The same joint as an older design has it: no x kept on either side. It is placed as
|
||||
// a frame from the normal alone places it, which here is where the kept x placed it too.
|
||||
let mut doc = (*s.doc).clone();
|
||||
for j in &mut doc.assembly.joints {
|
||||
for o in [&mut j.a, &mut j.b] {
|
||||
if let solidcraft_doc::joints::Snap::FaceCenter { x, .. } | solidcraft_doc::joints::Snap::CircleCenter { x, .. } = &mut o.snap {
|
||||
*x = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = serde_json::to_string(&doc.assembly).unwrap_or_default();
|
||||
assert!(!text.contains("\"x\""), "{text}");
|
||||
*s.doc_mut() = doc;
|
||||
run(&mut s, "joint.solve", json!({}));
|
||||
assert!((bar_from_tilt_axis(&s) - kept).abs() < 1e-6);
|
||||
}
|
||||
+74
-121
@@ -8,25 +8,22 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::joints::cam::{self, Cam, CamProfile, Follower};
|
||||
use solidcraft_doc::joints::{self, Coupling, Joint, JointExprs, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap, XRef};
|
||||
use solidcraft_doc::joints::{self, Coupling, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap, XRef};
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, str_, vec3};
|
||||
use crate::params::{bad, bool_, num, str_, string_list, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
|
||||
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | edge: [x,y,z] (a point on an edge of a planar face: the edge's middle, z out of the face, y into it; on?: [x,y,z] a point on the face (default: the larger planar face beside the edge); corner?: [x,y,z] near one end: the corner there, x and y along its two edges into the face; found again by name, so it follows the face as it changes size) | point: [x,y,z], z?: [x,y,z]; body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
|
||||
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | point: [x,y,z], z?: [x,y,z]; body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
|
||||
),
|
||||
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
|
||||
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball; a, b: joint origins ({occurrence, face|circle|edge|point: [x,y,z] in world, or face|circle: \"<face name>\"; end?, x? as joint.origin} | origin name); \
|
||||
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball; a, b: joint origins ({occurrence, face|circle|point: [x,y,z] in world, or face|circle: \"<face name>\"; end?, x? as joint.origin} | origin name); \
|
||||
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool (default false: the origins are mated, \
|
||||
b's z against a's as two faces meet; true: b's z along a's, so two origins given at one point with the same z stay as they are); \
|
||||
offset?: mm or an expression; angle?: deg or an expression (default: none, or a half turn where that turns b less, so faces that \
|
||||
already meet stay as they are); name?; quiet?: bool (leave out dof and fixed). An expression (text) is \
|
||||
kept with the joint and worked out again whenever the design changes, so the joint follows the parameters it names; a joint whose \
|
||||
expression no longer works out (a parameter deleted) is left out of the solve with the reason in its errors",
|
||||
offset?; angle?; name?; quiet?: bool (leave out dof and fixed)",
|
||||
),
|
||||
CommandSpec::new("joint.as_built", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
|
||||
"type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame; \
|
||||
@@ -34,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
CommandSpec::new("joint.rigid_group", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
|
||||
CommandSpec::new("joint.drive", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
|
||||
"joint: id|name; value | values: [angles in deg or expressions, distances] (values not given keep theirs; a value driven drops the expression joint.create gave it; clamped to the limits; the other joints of a \
|
||||
"joint: id|name; value | values: [angles in deg or expressions, distances] (values not given keep theirs; clamped to the limits; the other joints of a \
|
||||
closed loop follow, and the motion stops where the loop can no longer close → stopped_by_loop); quiet?: bool (leave out dof \
|
||||
and fixed) → values: [{name, value, unit: deg|mm, limits}] as joint.list gives them; moved: whether any occurrence moved; \
|
||||
reason: why the joint can move nothing (suppressed, its origins lost, or its chain joined to nothing grounded → unconnected: \
|
||||
@@ -68,56 +65,25 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is",
|
||||
),
|
||||
CommandSpec::new("joint.limits", "Joint Limits", limits).icon("joint").params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
|
||||
CommandSpec::new("joint.edit", "Edit Joint", edit)
|
||||
.icon("edit")
|
||||
.params("joint; type?, flip?, offset?, angle? (a number, or an expression the joint then follows), suppressed?, name?"),
|
||||
CommandSpec::new("joint.edit", "Edit Joint", edit).icon("edit").params("joint; type?, flip?, offset?, angle?, suppressed?, name?"),
|
||||
CommandSpec::new("joint.delete", "Delete Joint", delete).icon("delete").params("joint: id|name (or origin: name, link: index)"),
|
||||
CommandSpec::new("joint.list", "List Joints", list).icon("list").noundo().params(
|
||||
"→ joints (type, occurrences, values, limits; expressions: {offset?, angle?, values?} when they were typed as expressions), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \
|
||||
"→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \
|
||||
(joint chains joined to nothing grounded: the solver places none of them, so driving them moves nothing)",
|
||||
),
|
||||
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"),
|
||||
CommandSpec::new("inspect.interference", "Interference", super::interference::interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
|
||||
"bodies?: [names] (default: all, in world placement); budget_s?: seconds (default 240, at most 86400: pairs not finished by then are listed in unchecked as \"not checked: the time budget … ran out\" and the call returns) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre (a pin a hair off the axis of a hole of its size also has near_coaxial: {offset_mm, length_mm}, its volume the crescent the walls share); unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, then by their display meshes (pairs whose meshes stay apart by more than their chord tolerances, neither inside the other, are clear without a boolean), so any number of bodies is fine; the pairs left are intersected exactly, in parallel on desktop, the slowest first; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out). Also: pairs_candidate (boxes overlap), pairs_cleared (by the meshes), pairs_intersected, pairs_not_checked, budget_exceeded, threads (worker threads; 0: on the calling thread), boolean_ms and tessellate_ms (kernel time summed over the pairs), slowest: [{a, b, ms, boolean_ms, tessellate_ms}] and ms",
|
||||
CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
|
||||
"bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre (a pin a hair off the axis of a hole of its size also has near_coaxial: {offset_mm, length_mm}, its volume the crescent the walls share); unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)",
|
||||
),
|
||||
];
|
||||
|
||||
/// Re-solve the joints and move the occurrences they place (after any edit). Gears, racks and
|
||||
/// screws first take their ratios from the radii and pitches they are defined by.
|
||||
pub fn resolve(s: &mut Session) -> joints::Solution {
|
||||
reexpress(s);
|
||||
recouple(s);
|
||||
resolve_holding(s, &BTreeMap::new())
|
||||
}
|
||||
|
||||
/// Work out the offsets, angles and values joints were given as expressions again (a parameter
|
||||
/// may have changed). One that can't be worked out (its parameter gone) keeps its numbers; the
|
||||
/// solve leaves that joint out and says why.
|
||||
fn reexpress(s: &mut Session) {
|
||||
let changed: Vec<(usize, (f64, f64, Vec<f64>))> = s
|
||||
.doc
|
||||
.assembly
|
||||
.joints
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, j)| !j.exprs.is_empty())
|
||||
.filter_map(|(i, j)| {
|
||||
let (o, a, mut q) = joints::expressed(&s.doc, j).ok()?;
|
||||
joints::clamp(j, &mut q);
|
||||
((o, a) != (j.offset, j.angle) || q != j.values).then_some((i, (o, a, q)))
|
||||
})
|
||||
.collect();
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let doc = s.doc_mut();
|
||||
for (i, (o, a, q)) in changed {
|
||||
if let Some(j) = doc.assembly.joints.get_mut(i) {
|
||||
(j.offset, j.angle, j.values) = (o, a, q);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Work out the ratios (and cam tables) of the coupled motion links again (a radius, a parameter
|
||||
/// or a cam's profile may have changed). A coupling that can't be worked out (its face gone)
|
||||
/// keeps its last ratio and table; `joint.list` says why.
|
||||
@@ -313,11 +279,11 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<JointOrigin
|
||||
if let Some((what, name)) = ["face", "circle"].iter().find_map(|k| v.get(*k).and_then(Value::as_str).map(|n| (*k, n))) {
|
||||
return origin_by_name(s, v, what, name, cmd);
|
||||
}
|
||||
let pick = ["face", "circle", "edge", "point"].iter().find_map(|k| v.get(*k).and_then(vec3).map(|p| (*k, p)));
|
||||
let pick = ["face", "circle", "point"].iter().find_map(|k| v.get(*k).and_then(vec3).map(|p| (*k, p)));
|
||||
let (what, world) = match (pick, v.get("frame")) {
|
||||
(Some(x), _) => x,
|
||||
(None, Some(f)) => ("frame", f.get("origin").and_then(vec3).ok_or_else(|| bad(cmd, "`frame` needs origin and z"))?),
|
||||
_ => return Err(bad(cmd, "a joint origin needs face, circle, edge, point or frame [x, y, z], or a face's name")),
|
||||
_ => return Err(bad(cmd, "a joint origin needs face, circle, point or frame [x, y, z], or a face's name")),
|
||||
};
|
||||
let occurrence = match v.get("occurrence") {
|
||||
Some(o) => occurrence_id(s, Some(o), cmd)?,
|
||||
@@ -338,10 +304,6 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<JointOrigin
|
||||
let x = x_param(s, v, component, &inv, cmd)?;
|
||||
if what == "face" { Snap::FaceCenter { pick, face, x } } else { Snap::CircleCenter { pick, face, end: None, x } }
|
||||
}
|
||||
"edge" => {
|
||||
let (on, corner) = (v.get("on").and_then(vec3).map(local), v.get("corner").and_then(vec3).map(local));
|
||||
joints::edge_snap(&s.doc, &st, component, local(world), on, corner).map_err(|e| bad(cmd, e.to_string()))?
|
||||
}
|
||||
"point" => Snap::Point { point: local(world), z: v.get("z").and_then(vec3).map(local_dir) },
|
||||
_ => {
|
||||
let f = v.get("frame").cloned().unwrap_or_default();
|
||||
@@ -411,11 +373,8 @@ fn settle(s: &Session, mut o: JointOrigin, v: &Value, cmd: &str) -> Result<Joint
|
||||
return Err(bad(cmd, format!("the round face doesn't end on face `{want}` (its ends lie on: {})", names.join(", "))));
|
||||
}
|
||||
}
|
||||
let m = joints::resolve_origin(&s.doc, &st, &o).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
joints::resolve_origin(&s.doc, &st, &o).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
joints::pin_end(&s.doc, &st, &mut o.snap);
|
||||
// A face or circle given no `x` keeps the x it has now, so its frame doesn't turn as the face
|
||||
// tilts.
|
||||
o.snap = o.snap.with_frame_x(Vec3::new(m[0][0], m[0][1], m[0][2]));
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
@@ -483,28 +442,6 @@ pub(crate) fn value(s: &Session, v: &Value, angle: bool, cmd: &str) -> Result<f6
|
||||
s.doc.eval(&e, kind).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// The expression a number was typed as, kept so the joint follows the parameters it names
|
||||
/// (`None` for a plain number, or for text that names no parameter, such as `"5 mm"`).
|
||||
fn typed(v: Option<&Value>) -> Option<String> {
|
||||
match v {
|
||||
Some(Value::String(t)) if !solidcraft_doc::expr::references(t).is_empty() => Some(t.trim().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The expressions among `values` (or `value`), by index.
|
||||
fn value_exprs(p: &Value) -> Vec<Option<String>> {
|
||||
let mut out: Vec<Option<String>> = match (p.get("values"), p.get("value")) {
|
||||
(Some(Value::Array(a)), _) => a.iter().map(|v| typed(Some(v))).collect(),
|
||||
(_, Some(v)) => vec![typed(Some(v))],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
while out.last().is_some_and(Option::is_none) {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn values_param(s: &Session, p: &Value, kind: JointKind, cmd: &str) -> Result<Vec<f64>> {
|
||||
let n = kind.dofs().len();
|
||||
let list: Vec<Value> = match (p.get("values"), p.get("value")) {
|
||||
@@ -630,10 +567,10 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => 0.0,
|
||||
};
|
||||
let angle = match p.get("angle") {
|
||||
Some(v) => Some(value(s, v, true, cmd)?),
|
||||
None => None,
|
||||
Some(v) => value(s, v, true, cmd)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let mut j = Joint {
|
||||
let j = Joint {
|
||||
id,
|
||||
name,
|
||||
kind,
|
||||
@@ -643,15 +580,9 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
limits: limits_param(s, p, kind, cmd)?,
|
||||
flip: bool_(p, "flip").unwrap_or(false),
|
||||
offset,
|
||||
angle: angle.unwrap_or(0.0),
|
||||
angle,
|
||||
suppressed: false,
|
||||
exprs: JointExprs { offset: typed(p.get("offset")), angle: typed(p.get("angle")), values: value_exprs(p) },
|
||||
};
|
||||
// No angle given: B keeps its turn about the joint's axis where a half turn would otherwise
|
||||
// come only from how its origin's x was chosen.
|
||||
if angle.is_none() {
|
||||
j.angle = joints::least_turn_angle(&s.doc, &s.model.state(), &j);
|
||||
}
|
||||
add_joint(s, j, p)
|
||||
}
|
||||
|
||||
@@ -684,7 +615,6 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
offset: 0.0,
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
};
|
||||
add_joint(s, j, p)
|
||||
}
|
||||
@@ -729,11 +659,6 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
joints::clamp(j, &mut q);
|
||||
// Moved as far toward `q` as contact sets and closed loops allow. Values left out of
|
||||
// `values` are not driven: in a closed loop they follow.
|
||||
// Driving moves the joint by hand: the values driven drop their expressions (an expression
|
||||
// given here is worked out once, as contact or a loop may stop the motion short of it).
|
||||
for i in 0..given.min(kind.dofs().len()) {
|
||||
j.exprs.set_value(i, None);
|
||||
}
|
||||
let before = super::motion::positions(s);
|
||||
let r = super::motion::drive_joint(s, id, q, given)?;
|
||||
let sol = joints::solve(&s.doc, &s.model.state());
|
||||
@@ -1194,26 +1119,15 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = s.doc_mut();
|
||||
let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == id) else { return Err(bad(cmd, "joint")) };
|
||||
if let Some(k) = kind {
|
||||
let was = j.kind;
|
||||
j.kind = k;
|
||||
j.values.resize(k.dofs().len(), 0.0);
|
||||
j.limits.resize(k.dofs().len(), None);
|
||||
j.exprs.values.truncate(k.dofs().len());
|
||||
// A value that changes unit with the type (a slide becoming a turn) drops its expression,
|
||||
// which would no longer work out and leave the joint out of the solve.
|
||||
for i in 0..j.exprs.values.len() {
|
||||
if was.is_angle(i) != k.is_angle(i) {
|
||||
j.exprs.set_value(i, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(o) = offset {
|
||||
j.offset = o;
|
||||
j.exprs.offset = typed(p.get("offset"));
|
||||
}
|
||||
if let Some(a) = angle {
|
||||
j.angle = a;
|
||||
j.exprs.angle = typed(p.get("angle"));
|
||||
}
|
||||
if let Some(f) = bool_(p, "flip") {
|
||||
j.flip = f;
|
||||
@@ -1271,12 +1185,8 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|j| {
|
||||
let named = named_values(j, vals.get(&j.id).map(Vec::as_slice).unwrap_or_default());
|
||||
let world = |o: &JointOrigin| s.doc.origin_world(&st, o).ok().map(|m| json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]]}));
|
||||
let mut out = json!({"id": j.id, "name": j.name, "type": j.kind, "a": j.a.occurrence, "b": j.b.occurrence, "values": named,
|
||||
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "visible": s.joint_visible(j.id), "frame_a": world(&j.a), "frame_b": world(&j.b)});
|
||||
if !j.exprs.is_empty() {
|
||||
out["expressions"] = json!(j.exprs);
|
||||
}
|
||||
out
|
||||
json!({"id": j.id, "name": j.name, "type": j.kind, "a": j.a.occurrence, "b": j.b.occurrence, "values": named,
|
||||
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "visible": s.joint_visible(j.id), "frame_a": world(&j.a), "frame_b": world(&j.b)})
|
||||
})
|
||||
.collect();
|
||||
let links: Vec<Value> = s
|
||||
@@ -1320,6 +1230,61 @@ fn solve_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(sol_json(&sol, p))
|
||||
}
|
||||
|
||||
fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.world_state();
|
||||
let bodies: Vec<&solidcraft_doc::ModelBody> =
|
||||
st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).filter(|b| !b.body.is_mesh()).collect();
|
||||
// Prune by bounding boxes first (sort and sweep), so the cost follows the pairs that can
|
||||
// touch rather than the square of the body count.
|
||||
let boxes: Vec<Aabb3> = bodies.iter().map(|b| b.mesh().bounds()).collect();
|
||||
let (pairs, skipped) = overlapping_pairs(&boxes, MAX_INTERFERENCE_PAIRS);
|
||||
let (mut hits, mut unchecked) = (Vec::new(), Vec::new());
|
||||
for (i, j) in pairs {
|
||||
let (Some(a), Some(b)) = (bodies.get(i), bodies.get(j)) else { continue };
|
||||
let threshold = 1e-9 * a.body.size().max(1.0).powi(3);
|
||||
// A pin a hair off the axis of its hole: the crescent the walls share, which the
|
||||
// boolean can't intersect.
|
||||
if let Some(o) = solidcraft_kernel::coaxial_overlap(&a.body, &b.body) {
|
||||
if o.volume > threshold {
|
||||
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": o.volume, "area_mm2": o.area, "center": o.centroid,
|
||||
"bbox": {"min": o.bbox.min, "max": o.bbox.max}, "near_coaxial": {"offset_mm": o.offset, "length_mm": o.length}}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A pair whose shared part can't be worked out is reported, not passed as clear.
|
||||
let (shared, report) =
|
||||
solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect));
|
||||
let mut skip = |reason: String| unchecked.push(json!({"a": a.name, "b": b.name, "reason": reason}));
|
||||
let shared = match shared {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(reason) = report.unverified() {
|
||||
skip(reason);
|
||||
}
|
||||
let Some(o) = shared else { continue };
|
||||
let m = match solidcraft_kernel::measure(&o) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if m.volume > threshold {
|
||||
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
|
||||
}
|
||||
}
|
||||
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked,
|
||||
"truncated": skipped > 0, "skipped_pairs": skipped}))
|
||||
}
|
||||
|
||||
/// Most pairs with overlapping bounding boxes `inspect.interference` intersects in one call.
|
||||
const MAX_INTERFERENCE_PAIRS: usize = 20_000;
|
||||
|
||||
/// Index pairs `(i, j)`, `i < j`, whose boxes overlap or touch (sort and sweep along X), at most
|
||||
/// `limit` of them, plus how many more there were.
|
||||
pub(crate) fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, usize)>, usize) {
|
||||
@@ -1351,24 +1316,12 @@ pub(crate) fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, u
|
||||
(pairs, skipped)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "joint_edge_tests.rs"]
|
||||
mod edge_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_exprs_tests.rs"]
|
||||
mod exprs_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_face_tests.rs"]
|
||||
mod face_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joint_frames_tests.rs"]
|
||||
mod frames_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_reach_tests.rs"]
|
||||
mod reach_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_tests.rs"]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_turn_tests.rs"]
|
||||
mod turn_tests;
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
//! Joints whose offset, angle or values are typed as expressions follow the parameters they name.
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::tests::{occ, place, run};
|
||||
use crate::Session;
|
||||
|
||||
/// Two 10 mm cubes in components A (grounded) and B, a `gap` and a `turn` parameter.
|
||||
fn cubes() -> Session {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 20)] {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
|
||||
}
|
||||
let o = occ(&s, "A");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
|
||||
run(&mut s, "parameters.add", json!({"name": "gap", "expression": "5 mm"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "turn", "expression": "0 deg"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// Where B's bottom corner (20, 0, 0 in B) is.
|
||||
fn b_corner(s: &Session) -> Vec3 {
|
||||
place(s, "B", Vec3::new(20.0, 0.0, 0.0))
|
||||
}
|
||||
|
||||
fn near(a: Vec3, b: Vec3) -> bool {
|
||||
a.dist(b) < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offset_and_an_angle_typed_as_expressions_follow_their_parameters() {
|
||||
let mut s = cubes();
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "offset": "gap", "angle": "turn", "name": "Seat"}),
|
||||
);
|
||||
let z = |s: &Session| place(s, "B", Vec3::new(25.0, 5.0, 0.0)).z;
|
||||
assert!((z(&s) - 15.0).abs() < 1e-6, "{}", z(&s));
|
||||
// The gap changes: B rides up with it, without touching the joint.
|
||||
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "12 mm"}));
|
||||
assert!((z(&s) - 22.0).abs() < 1e-6, "{}", z(&s));
|
||||
// A parameter the gap is worked out from changes too.
|
||||
run(&mut s, "parameters.add", json!({"name": "base", "expression": "2 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "base * 3"}));
|
||||
assert!((z(&s) - 16.0).abs() < 1e-6, "{}", z(&s));
|
||||
// The angle turns B about the seat's axis (its centre stays, its corner swings round).
|
||||
let centre = place(&s, "B", Vec3::new(25.0, 5.0, 0.0));
|
||||
let before = b_corner(&s);
|
||||
run(&mut s, "parameters.change", json!({"name": "turn", "expression": "90 deg"}));
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), centre));
|
||||
let after = b_corner(&s);
|
||||
assert!((before - centre).dot(after - centre).abs() < 1e-6, "{before:?} {after:?}");
|
||||
// The design keeps the expressions, so they are there after saving and opening.
|
||||
let j = s.doc.assembly.joints.first().cloned().unwrap_or_else(|| panic!("no joint"));
|
||||
assert_eq!((j.exprs.offset.as_deref(), j.exprs.angle.as_deref()), (Some("gap"), Some("turn")));
|
||||
let saved = serde_json::to_value(&j).unwrap_or_default();
|
||||
assert_eq!((&saved["exprs"]["offset"], &saved["exprs"]["angle"]), (&json!("gap"), &json!("turn")), "{saved}");
|
||||
let listed = run(&mut s, "joint.list", json!({}));
|
||||
assert_eq!(listed["joints"][0]["expressions"]["offset"], json!("gap"), "{listed}");
|
||||
// A number typed over the expression drops it: B stays when the gap changes again.
|
||||
run(&mut s, "joint.edit", json!({"joint": "Seat", "offset": 4}));
|
||||
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "30 mm"}));
|
||||
assert!((z(&s) - 14.0).abs() < 1e-6, "{}", z(&s));
|
||||
assert!(s.doc.assembly.joints.first().is_some_and(|j| j.exprs.offset.is_none() && j.exprs.angle.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_joint_value_typed_as_an_expression_follows_it_until_it_is_driven() {
|
||||
let mut s = cubes();
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "values": ["gap"], "limits": [[0, 20]], "name": "Rail"}),
|
||||
);
|
||||
let z = |s: &Session| place(s, "B", Vec3::new(25.0, 5.0, 0.0)).z;
|
||||
assert!((z(&s) - 15.0).abs() < 1e-6, "{}", z(&s));
|
||||
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "8 mm"}));
|
||||
assert!((z(&s) - 18.0).abs() < 1e-6, "{}", z(&s));
|
||||
// Past the limits it is held at them, like a typed value.
|
||||
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "50 mm"}));
|
||||
assert!((z(&s) - 30.0).abs() < 1e-6, "{}", z(&s));
|
||||
// Driven by hand it drops the expression (contact or a loop may stop it short of any value).
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 1}));
|
||||
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "9 mm"}));
|
||||
assert!((z(&s) - 11.0).abs() < 1e-6, "{}", z(&s));
|
||||
assert!(s.doc.assembly.joints.first().is_some_and(|j| j.exprs.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_parameter_carries_into_joints_and_deleting_it_is_refused() {
|
||||
let mut s = cubes();
|
||||
run(&mut s, "joint.create", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "offset": "gap + 1", "name": "Seat"}));
|
||||
run(&mut s, "parameters.rename", json!({"name": "gap", "new_name": "clearance"}));
|
||||
assert_eq!(s.doc.assembly.joints.first().and_then(|j| j.exprs.offset.clone()).as_deref(), Some("clearance + 1"));
|
||||
run(&mut s, "parameters.change", json!({"name": "clearance", "expression": "9 mm"}));
|
||||
assert!((place(&s, "B", Vec3::new(25.0, 5.0, 0.0)).z - 20.0).abs() < 1e-6);
|
||||
// The joint uses it, so it can't be deleted from under the joint.
|
||||
let e = s.execute("parameters.delete", &json!({"name": "clearance"})).unwrap_err().to_string();
|
||||
assert!(e.contains("joint Seat (offset)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_joint_whose_expression_stops_working_out_is_left_out_with_the_reason() {
|
||||
let mut s = cubes();
|
||||
run(&mut s, "joint.create", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "offset": "gap", "name": "Seat"}));
|
||||
let at = b_corner(&s);
|
||||
// The gap becomes an angle: the offset no longer works out as a length. B stays where it
|
||||
// was (not thrown to an old number) and the solve says why.
|
||||
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "30 deg", "unit": "deg"}));
|
||||
assert!(near(b_corner(&s), at), "{:?}", b_corner(&s));
|
||||
let r = run(&mut s, "joint.solve", json!({}));
|
||||
let why = r["errors"][0]["why"].as_str().unwrap_or_default().to_string();
|
||||
assert!(why.contains("Seat") && why.contains("offset"), "{r}");
|
||||
// Fixed again, the joint places B again.
|
||||
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "2 mm", "unit": "mm"}));
|
||||
assert!((place(&s, "B", Vec3::new(25.0, 5.0, 0.0)).z - 12.0).abs() < 1e-6);
|
||||
assert_eq!(run(&mut s, "joint.solve", json!({}))["errors"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_that_changes_unit_with_the_joint_type_drops_its_expression() {
|
||||
let mut s = cubes();
|
||||
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "values": ["gap"], "name": "Rail"}));
|
||||
// The slide becomes a turn: `gap` (a length) can't be its angle, so the turn keeps a number
|
||||
// and the joint stays in the solve.
|
||||
let r = run(&mut s, "joint.edit", json!({"joint": "Rail", "type": "revolute"}));
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
assert!(s.doc.assembly.joints.first().is_some_and(|j| j.exprs.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_names_no_parameter_is_kept_as_a_number() {
|
||||
let mut s = cubes();
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "offset": "3 mm", "angle": "0 deg", "values": ["2 * 2 mm"]}),
|
||||
);
|
||||
let j = s.doc.assembly.joints.first().cloned().unwrap_or_else(|| panic!("no joint"));
|
||||
assert!(j.exprs.is_empty(), "{:?}", j.exprs);
|
||||
assert!((j.offset - 3.0).abs() < 1e-12 && j.values.first().is_some_and(|v| (v - 4.0).abs() < 1e-12), "{j:?}");
|
||||
}
|
||||
@@ -265,31 +265,3 @@ fn origins_on_a_body_placed_by_an_offset_agree_named_or_not() {
|
||||
assert!(near(named.0, unnamed.0) && near(named.1, unnamed.1), "{named:?} {unnamed:?}");
|
||||
assert!(near(named.0, at(Vec3::new(20.0, 20.0, 5.0))) && near(named.1, up), "{named:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_origin_keeps_its_end_when_the_face_it_opens_onto_gets_other_neighbours() {
|
||||
// A plate `T` thick with a groove across its top (so the top face is in pieces, each named
|
||||
// by the faces round it), a small hole and a 6 mm hole in the left piece. The origin is
|
||||
// picked on the 6 mm hole's wall near the top, and names the top piece as its end.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "T", "expression": "2 mm"}));
|
||||
run(&mut s, "component.create", json!({"name": "Plate"}));
|
||||
run(&mut s, "solid.box", json!({"length": 60, "width": 100, "height": "T", "corner": [0, 0, 0]}));
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 100, "height": 1, "corner": [28, 0, "T - 1 mm"], "operation": "cut"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [10, 50, "T"], "diameter": 4}));
|
||||
let small = s.doc.features.last().map_or(0, |f| f.id);
|
||||
run(&mut s, "solid.hole", json!({"position": [20, 50, "T"], "diameter": 6}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "joint.origin", json!({"occurrence": "Plate", "circle": [23, 50, 1.6], "name": "O"}));
|
||||
let top = |s: &Session| {
|
||||
let o = s.doc.assembly.origins.iter().find(|o| o.name == "O").map(|o| o.origin.clone());
|
||||
o.and_then(|o| solidcraft_doc::joints::resolve_origin(&s.doc, &s.model.state(), &o).ok()).map(|m| (m[3][2], m[2][2]))
|
||||
};
|
||||
assert_eq!(top(&s), Some((2.0, 1.0)));
|
||||
// Thicker, the pick is nearer the bottom; without the small hole the top piece is named by
|
||||
// fewer faces. The origin stays on the top.
|
||||
run(&mut s, "parameters.change", json!({"name": "T", "expression": "4 mm"}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature": small, "suppressed": true}));
|
||||
let (z, dz) = top(&s).unwrap_or_default();
|
||||
assert!((z - 4.0).abs() < 1e-9 && (dz - 1.0).abs() < 1e-9, "{z} {dz}");
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
//! A new joint given no angle keeps the part's turn about the joint's axis: two faces that
|
||||
//! already meet stay as they are instead of the part spinning a half turn about their normal.
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::tests::{occ, place, run};
|
||||
use crate::Session;
|
||||
|
||||
/// A grounded panel 80 x 30 x 3 at z 3 and a hinge plate of the same size under it, each with a
|
||||
/// hole off its middle at x = 20: the two faces that meet at z = 3 have opposite normals.
|
||||
fn panel_and_hinge() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Panel"}));
|
||||
run(&mut s, "solid.box", json!({"length": 80, "width": 30, "height": 3, "corner": [0, 0, 3]}));
|
||||
run(&mut s, "solid.hole", json!({"position": [20, 15, 6], "diameter": 5.5}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Hinge"}));
|
||||
run(&mut s, "solid.box", json!({"length": 80, "width": 30, "height": 3, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "solid.hole", json!({"position": [20, 15, 3], "diameter": 5.5}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let panel = occ(&s, "Panel");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": panel, "grounded": true}));
|
||||
s
|
||||
}
|
||||
|
||||
fn stays(s: &Session) -> bool {
|
||||
[Vec3::new(20.0, 15.0, 0.0), Vec3::new(80.0, 0.0, 3.0)].iter().all(|p| place(s, "Hinge", *p).dist(*p) < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joining_two_faces_that_already_meet_leaves_the_part_where_it_is() {
|
||||
// Each face's frame takes an x of its own; with opposite normals those x are opposite, and
|
||||
// the joint used to spin the hinge a half turn about the normal, its hole to x = 60.
|
||||
for kind in ["rigid", "slider", "planar"] {
|
||||
let mut s = panel_and_hinge();
|
||||
let r = run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": kind, "a": {"occurrence": "Panel", "face": [30, 25, 3]}, "b": {"occurrence": "Hinge", "face": [30, 25, 3]}, "name": "J"}),
|
||||
);
|
||||
assert!(stays(&s), "{kind}: the hinge moved to {:?}: {r}", place(&s, "Hinge", Vec3::new(20.0, 15.0, 0.0)));
|
||||
// Its value still moves it along the joint's axis (the faces' normal), without a turn.
|
||||
if kind == "slider" {
|
||||
run(&mut s, "joint.drive", json!({"joint": "J", "value": 5}));
|
||||
let p = place(&s, "Hinge", Vec3::new(20.0, 15.0, 0.0));
|
||||
assert!(p.dist(Vec3::new(20.0, 15.0, -5.0)) < 1e-6, "{p:?}");
|
||||
}
|
||||
}
|
||||
// An angle given is kept as given.
|
||||
let mut s = panel_and_hinge();
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "face": [30, 25, 3]}, "b": {"occurrence": "Hinge", "face": [30, 25, 3]}, "angle": 0}),
|
||||
);
|
||||
assert!(!stays(&s));
|
||||
let j = s.doc.assembly.joints.last().map(|j| j.angle);
|
||||
assert_eq!(j, Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_angle_typed_as_an_expression_is_not_replaced_by_the_kept_turn() {
|
||||
// `turn` is 0 deg: the hinge takes the half turn the angle says, not the one that would
|
||||
// leave it where it is, and follows the parameter when it changes.
|
||||
let mut s = panel_and_hinge();
|
||||
run(&mut s, "parameters.add", json!({"name": "turn", "expression": "0 deg"}));
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "face": [30, 25, 3]}, "b": {"occurrence": "Hinge", "face": [30, 25, 3]}, "angle": "turn", "name": "J"}),
|
||||
);
|
||||
assert!(!stays(&s));
|
||||
let j = s.doc.assembly.joints.last().map(|j| (j.angle, j.exprs.angle.clone()));
|
||||
assert_eq!(j, Some((0.0, Some("turn".to_string()))));
|
||||
run(&mut s, "parameters.change", json!({"name": "turn", "expression": "180 deg"}));
|
||||
assert!(stays(&s), "{:?}", place(&s, "Hinge", Vec3::new(20.0, 15.0, 0.0)));
|
||||
assert!(s.doc.assembly.joints.last().is_some_and(|j| j.exprs.angle.as_deref() == Some("turn")));
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! have a direction (planar faces, straight edges), the angle.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::{Mesh, UnitVec3, Vec3};
|
||||
use solidcraft_geom::{Mesh, Vec3};
|
||||
|
||||
use super::measure_shape::{Shape, Slot, add_round, centre};
|
||||
use crate::params::bad;
|
||||
@@ -84,7 +84,7 @@ struct Item {
|
||||
|
||||
/// The circle through a polyline's points (all on one within `tol`): a fallback for edges the
|
||||
/// kernel can't name (mesh bodies).
|
||||
fn fit_circle(pts: &[Vec3], tol: f64) -> Option<(Vec3, UnitVec3, f64)> {
|
||||
fn fit_circle(pts: &[Vec3], tol: f64) -> Option<(Vec3, Vec3, f64)> {
|
||||
let n = pts.len().checked_sub(1)?;
|
||||
let (a, b, c) = (*pts.first()?, *pts.get(n / 3)?, *pts.get(2 * n / 3)?);
|
||||
let (u, v) = (b - a, c - a);
|
||||
@@ -93,7 +93,7 @@ fn fit_circle(pts: &[Vec3], tol: f64) -> Option<(Vec3, UnitVec3, f64)> {
|
||||
return None;
|
||||
}
|
||||
let center = a + (v * u.len2() - u * v.len2()).cross(w) * (1.0 / (2.0 * w.len2()));
|
||||
let axis = w.unit()?;
|
||||
let axis = w.normalized()?;
|
||||
let radius = center.dist(a);
|
||||
let on = pts.iter().all(|p| (p.dist(center) - radius).abs() <= tol && (*p - center).dot(axis).abs() <= tol);
|
||||
(on && radius.is_finite()).then_some((center, axis, radius))
|
||||
@@ -123,7 +123,7 @@ fn edge_round(b: &solidcraft_doc::ModelBody, m: &Mesh, index: usize, whole: bool
|
||||
}
|
||||
|
||||
/// Points along a circle or arc (`angle` from `start` about `axis`).
|
||||
fn arc_points(center: Vec3, axis: UnitVec3, start: Vec3, angle: f64) -> Vec<Vec3> {
|
||||
fn arc_points(center: Vec3, axis: Vec3, start: Vec3, angle: f64) -> Vec<Vec3> {
|
||||
let x = start - center;
|
||||
let y = axis.cross(x);
|
||||
let n = 128;
|
||||
@@ -157,7 +157,7 @@ fn sketch_curve(ss: &solidcraft_doc::SolvedSketch, id: &str) -> Option<Item> {
|
||||
let sk = &ss.sketch;
|
||||
let c = sk.curves.get(sk.curve_index(id)?)?;
|
||||
let at = |i: usize| sk.points.get(i).map(|p| ss.plane.to_world(p.pos));
|
||||
let normal = ss.plane.normal().unit()?;
|
||||
let normal = ss.plane.normal();
|
||||
let (shape, pts) = match c.kind {
|
||||
CurveKind::Circle { c, r } => {
|
||||
let center = at(c)?;
|
||||
@@ -203,7 +203,7 @@ fn profile(ss: &solidcraft_doc::SolvedSketch, index: usize) -> Option<Item> {
|
||||
let pr = ss.profiles.get(index)?;
|
||||
let outline: Vec<Vec3> = pr.region.outer.polyline(1e-3).iter().map(|p| ss.plane.to_world(*p)).collect();
|
||||
let mut json = json!({"type": "profile", "area_mm2": pr.region.area()});
|
||||
let normal = ss.plane.normal().unit()?;
|
||||
let normal = ss.plane.normal();
|
||||
// Arcs about one centre merge (a circle is two halves); straight pieces stay as they are.
|
||||
let mut arcs: Vec<(Vec2, f64, f64)> = Vec::new();
|
||||
let mut lines: Vec<(Vec2, Vec2)> = Vec::new();
|
||||
@@ -242,7 +242,7 @@ fn profile(ss: &solidcraft_doc::SolvedSketch, index: usize) -> Option<Item> {
|
||||
slot = Some(s);
|
||||
}
|
||||
}
|
||||
let planar = Some(*normal);
|
||||
let planar = Some(normal);
|
||||
let geo = Geo::Points(densify(&outline));
|
||||
let shape = if matches!(shape, Shape::Other) { Shape::Plane { point: ss.plane.origin, normal } } else { shape };
|
||||
Some(Item { geo, dir: planar, json, shape, slot })
|
||||
@@ -308,7 +308,7 @@ fn describe(s: &Session, sel: &Sel) -> Option<Item> {
|
||||
let patch = m.surface_patch(*index);
|
||||
let tris = patch.iter().flat_map(|f| face_tris(&m, *f)).collect::<Vec<_>>();
|
||||
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
|
||||
let normals: Vec<UnitVec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).unit()).collect();
|
||||
let normals: Vec<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
|
||||
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
|
||||
let mut json = json!({"type": "face", "area_mm2": area, "planar": planar.is_some()});
|
||||
let verts: Vec<Vec3> = tris.iter().flat_map(|t| t.iter().copied()).collect();
|
||||
@@ -328,7 +328,7 @@ fn describe(s: &Session, sel: &Sel) -> Option<Item> {
|
||||
if let Some(sl) = &slot {
|
||||
json["slot"] = sl.json();
|
||||
}
|
||||
Some(Item { geo: Geo::Tris(tris), dir: planar.map(Vec3::from), json, shape, slot })
|
||||
Some(Item { geo: Geo::Tris(tris), dir: planar, json, shape, slot })
|
||||
}
|
||||
Sel::Body { name } => {
|
||||
let m = st.body(name)?.mesh();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! round item measures from its centre or axis, with the closest and farthest distances too.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::{UnitVec3, Vec3};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
@@ -17,7 +17,7 @@ pub(crate) enum Shape {
|
||||
/// around the axis (2π all the way round).
|
||||
Round {
|
||||
center: Vec3,
|
||||
axis: UnitVec3,
|
||||
axis: Vec3,
|
||||
radius: f64,
|
||||
angle: f64,
|
||||
planar: bool,
|
||||
@@ -26,7 +26,7 @@ pub(crate) enum Shape {
|
||||
},
|
||||
Plane {
|
||||
point: Vec3,
|
||||
normal: UnitVec3,
|
||||
normal: Vec3,
|
||||
},
|
||||
Line {
|
||||
a: Vec3,
|
||||
@@ -70,7 +70,8 @@ pub(crate) fn add_round(v: &mut Value, shape: &Shape) {
|
||||
|
||||
/// Cylinder `axis` through `origin` of radius `radius` over the points of its face: the
|
||||
/// round shape, centred level with the middle of the face, with its extent and coverage.
|
||||
pub(crate) fn cylinder(origin: Vec3, axis: UnitVec3, radius: f64, pts: &[Vec3]) -> Option<Shape> {
|
||||
pub(crate) fn cylinder(origin: Vec3, axis: Vec3, radius: f64, pts: &[Vec3]) -> Option<Shape> {
|
||||
let axis = axis.normalized()?;
|
||||
if pts.is_empty() || !(radius.is_finite() && radius > 0.0) {
|
||||
return None;
|
||||
}
|
||||
@@ -82,7 +83,7 @@ pub(crate) fn cylinder(origin: Vec3, axis: UnitVec3, radius: f64, pts: &[Vec3])
|
||||
}
|
||||
|
||||
/// The angle around the axis the points cover: a whole turn less the widest gap between them.
|
||||
pub(crate) fn coverage(center: Vec3, axis: UnitVec3, pts: &[Vec3]) -> f64 {
|
||||
pub(crate) fn coverage(center: Vec3, axis: Vec3, pts: &[Vec3]) -> f64 {
|
||||
let x = axis.any_perp();
|
||||
let y = axis.cross(x);
|
||||
let mut angles: Vec<f64> = pts
|
||||
@@ -104,7 +105,7 @@ pub(crate) fn coverage(center: Vec3, axis: UnitVec3, pts: &[Vec3]) -> f64 {
|
||||
}
|
||||
|
||||
/// The unit direction a point leaves the axis at, towards the middle of the face's points.
|
||||
fn bulge(center: Vec3, axis: UnitVec3, pts: &[Vec3]) -> Option<Vec3> {
|
||||
fn bulge(center: Vec3, axis: Vec3, pts: &[Vec3]) -> Option<Vec3> {
|
||||
let sum = pts.iter().fold(Vec3::ZERO, |acc, p| {
|
||||
let d = *p - center;
|
||||
acc + (d - axis * d.dot(axis)).normalized().unwrap_or(Vec3::ZERO)
|
||||
@@ -113,7 +114,7 @@ fn bulge(center: Vec3, axis: UnitVec3, pts: &[Vec3]) -> Option<Vec3> {
|
||||
}
|
||||
|
||||
/// Closest points of two infinite lines (a point and a unit direction each).
|
||||
fn line_line(p1: Vec3, d1: UnitVec3, p2: Vec3, d2: UnitVec3) -> (Vec3, Vec3) {
|
||||
fn line_line(p1: Vec3, d1: Vec3, p2: Vec3, d2: Vec3) -> (Vec3, Vec3) {
|
||||
let w = p1 - p2;
|
||||
let b = d1.dot(d2);
|
||||
let denom = 1.0 - b * b;
|
||||
@@ -126,8 +127,8 @@ fn line_line(p1: Vec3, d1: UnitVec3, p2: Vec3, d2: UnitVec3) -> (Vec3, Vec3) {
|
||||
(p1 + d1 * s, p2 + d2 * t)
|
||||
}
|
||||
|
||||
fn parallel(a: impl Into<Vec3>, b: impl Into<Vec3>) -> bool {
|
||||
a.into().cross(b).len() < 1e-6
|
||||
fn parallel(a: Vec3, b: Vec3) -> bool {
|
||||
a.cross(b).len() < 1e-6
|
||||
}
|
||||
|
||||
/// A measurement from a round item's centre or axis.
|
||||
@@ -213,7 +214,7 @@ fn from_round(a: &Shape, b: &Shape) -> Option<Centre> {
|
||||
}
|
||||
}
|
||||
Shape::Line { a: la, b: lb } => {
|
||||
let dir = (lb - la).unit()?;
|
||||
let dir = (lb - la).normalized()?;
|
||||
let across = dir.dot(na).abs() < 1e-6;
|
||||
if pa {
|
||||
// A circle/arc has a fixed centre. Project that point onto the picked
|
||||
@@ -341,7 +342,7 @@ mod tests {
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
fn circle(c: Vec3, r: f64) -> Shape {
|
||||
Shape::Round { center: c, axis: UnitVec3::Z, radius: r, angle: TAU, planar: true, lo: 0.0, hi: 0.0 }
|
||||
Shape::Round { center: c, axis: Vec3::Z, radius: r, angle: TAU, planar: true, lo: 0.0, hi: 0.0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -366,8 +367,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn arcs_on_different_planes_measure_between_their_centres() {
|
||||
let a = Shape::Round { center: Vec3::ZERO, axis: UnitVec3::Z, radius: 2.0, angle: PI, planar: true, lo: 0.0, hi: 0.0 };
|
||||
let b = Shape::Round { center: Vec3::new(3.0, 4.0, 12.0), axis: UnitVec3::X, radius: 4.0, angle: PI / 2.0, planar: true, lo: 0.0, hi: 0.0 };
|
||||
let a = Shape::Round { center: Vec3::ZERO, axis: Vec3::Z, radius: 2.0, angle: PI, planar: true, lo: 0.0, hi: 0.0 };
|
||||
let b = Shape::Round { center: Vec3::new(3.0, 4.0, 12.0), axis: Vec3::X, radius: 4.0, angle: PI / 2.0, planar: true, lo: 0.0, hi: 0.0 };
|
||||
let c = centre(&a, &b).unwrap();
|
||||
assert_eq!((c.from, c.to, c.distance), (Vec3::ZERO, Vec3::new(3.0, 4.0, 12.0), 13.0));
|
||||
assert!(c.min.is_none() && c.max.is_none());
|
||||
@@ -376,10 +377,10 @@ mod tests {
|
||||
#[test]
|
||||
fn a_circle_to_a_cylinder_uses_its_fixed_centre_and_the_cylinders_axis() {
|
||||
let a = circle(Vec3::new(0.0, 0.0, 10.0), 4.0);
|
||||
for axis in [UnitVec3::Z, UnitVec3::X] {
|
||||
for axis in [Vec3::Z, Vec3::X] {
|
||||
let b = Shape::Round { center: Vec3::new(34.0, 19.0, 27.0), axis, radius: 4.0, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
|
||||
let c = centre(&a, &b).unwrap();
|
||||
let expected = if axis == UnitVec3::Z { Vec3::new(34.0, 19.0, 10.0) } else { Vec3::new(0.0, 19.0, 27.0) };
|
||||
let expected = if axis == Vec3::Z { Vec3::new(34.0, 19.0, 10.0) } else { Vec3::new(0.0, 19.0, 27.0) };
|
||||
assert_eq!((c.from, c.to), (Vec3::new(0.0, 0.0, 10.0), expected));
|
||||
assert!((c.distance - c.from.dist(expected)).abs() < 1e-12);
|
||||
let swapped = centre(&b, &a).unwrap();
|
||||
@@ -389,8 +390,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn two_cylindrical_faces_still_measure_axis_pitch() {
|
||||
let a = Shape::Round { center: Vec3::new(0.0, 0.0, 5.0), axis: UnitVec3::Z, radius: 4.0, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
|
||||
let b = Shape::Round { center: Vec3::new(34.0, 19.0, 27.0), axis: UnitVec3::Z, radius: 4.0, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
|
||||
let a = Shape::Round { center: Vec3::new(0.0, 0.0, 5.0), axis: Vec3::Z, radius: 4.0, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
|
||||
let b = Shape::Round { center: Vec3::new(34.0, 19.0, 27.0), axis: Vec3::Z, radius: 4.0, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
|
||||
let c = centre(&a, &b).unwrap();
|
||||
assert_eq!((c.from, c.to), (Vec3::new(0.0, 0.0, 5.0), Vec3::new(34.0, 19.0, 5.0)));
|
||||
assert!((c.distance - 1517_f64.sqrt()).abs() < 1e-12);
|
||||
@@ -398,8 +399,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_cylinder_to_a_side_plane_measures_from_its_axis() {
|
||||
let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: UnitVec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
|
||||
let plane = Shape::Plane { point: Vec3::ZERO, normal: -UnitVec3::X };
|
||||
let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: Vec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
|
||||
let plane = Shape::Plane { point: Vec3::ZERO, normal: Vec3::new(-1.0, 0.0, 0.0) };
|
||||
let c = centre(&cyl, &plane).unwrap();
|
||||
assert!((c.distance - 10.0).abs() < 1e-12);
|
||||
assert!((c.min.unwrap() - 7.5).abs() < 1e-12 && (c.max.unwrap() - 12.5).abs() < 1e-12);
|
||||
@@ -407,12 +408,12 @@ mod tests {
|
||||
let d = centre(&plane, &cyl).unwrap();
|
||||
assert_eq!((d.from, d.to), (c.to, c.from));
|
||||
// A cylinder crossing a plane has no single axis distance.
|
||||
assert!(centre(&cyl, &Shape::Plane { point: Vec3::ZERO, normal: UnitVec3::Z }).is_none());
|
||||
assert!(centre(&cyl, &Shape::Plane { point: Vec3::ZERO, normal: Vec3::Z }).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_axis_to_a_crossing_edge_is_the_distance_between_the_lines() {
|
||||
let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: UnitVec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
|
||||
let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: Vec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
|
||||
let edge = Shape::Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(40.0, 0.0, 10.0) };
|
||||
let c = centre(&cyl, &edge).unwrap();
|
||||
assert!((c.distance - 15.0).abs() < 1e-12);
|
||||
@@ -424,7 +425,7 @@ mod tests {
|
||||
fn a_circle_or_arc_to_a_line_keeps_its_real_center() {
|
||||
let edge = Shape::Line { a: Vec3::new(0.0, 0.0, 6.0), b: Vec3::new(68.0, 0.0, 6.0) };
|
||||
for angle in [TAU, PI / 2.0] {
|
||||
let round = Shape::Round { center: Vec3::new(34.0, 25.0, 20.0), axis: UnitVec3::Z, radius: 12.0, angle, planar: true, lo: 0.0, hi: 0.0 };
|
||||
let round = Shape::Round { center: Vec3::new(34.0, 25.0, 20.0), axis: Vec3::Z, radius: 12.0, angle, planar: true, lo: 0.0, hi: 0.0 };
|
||||
let c = centre(&round, &edge).unwrap();
|
||||
assert_eq!((c.from, c.to), (Vec3::new(34.0, 25.0, 20.0), Vec3::new(34.0, 0.0, 6.0)));
|
||||
assert!((c.distance - 25_f64.hypot(14.0)).abs() < 1e-12);
|
||||
@@ -441,7 +442,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_tilted_arc_to_a_sloped_line_keeps_all_components() {
|
||||
let edge = Shape::Line { a: Vec3::ZERO, b: Vec3::new(6.0, 0.0, 6.0) };
|
||||
for axis in [UnitVec3::X, UnitVec3::Y, UnitVec3::Z] {
|
||||
for axis in [Vec3::X, Vec3::Y, Vec3::Z] {
|
||||
let arc = Shape::Round { center: Vec3::new(3.0, 4.0, 6.0), axis, radius: 2.0, angle: PI, planar: true, lo: 0.0, hi: 0.0 };
|
||||
let c = centre(&arc, &edge).unwrap();
|
||||
assert!(c.from.dist(Vec3::new(3.0, 4.0, 6.0)) < 1e-12);
|
||||
@@ -467,8 +468,8 @@ mod tests {
|
||||
let ring = |n: usize, span: f64| -> Vec<Vec3> {
|
||||
(0..=n).map(|i| Vec3::new((span * i as f64 / n as f64).cos(), (span * i as f64 / n as f64).sin(), 0.0)).collect()
|
||||
};
|
||||
assert!((coverage(Vec3::ZERO, UnitVec3::Z, &ring(32, PI)) - PI).abs() < 1e-9);
|
||||
assert_eq!(coverage(Vec3::ZERO, UnitVec3::Z, &ring(32, TAU)), TAU);
|
||||
assert!((coverage(Vec3::ZERO, Vec3::Z, &ring(32, PI)) - PI).abs() < 1e-9);
|
||||
assert_eq!(coverage(Vec3::ZERO, Vec3::Z, &ring(32, TAU)), TAU);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -39,7 +39,6 @@ mod component_link;
|
||||
mod config;
|
||||
mod file;
|
||||
mod inspect;
|
||||
mod interference;
|
||||
pub(crate) mod joints;
|
||||
mod libraries;
|
||||
mod material_properties;
|
||||
|
||||
@@ -580,85 +580,3 @@ fn compact_component_list_counts_and_skips_identity_transforms() {
|
||||
// Body names on request.
|
||||
assert_eq!(block(&run(&mut s, "component.list", json!({"compact": true, "bodies": true})))["bodies"], json!(["Cube"]));
|
||||
}
|
||||
|
||||
/// A script's 3x4 placement matrix (rotation rows, then the translation column) moves an
|
||||
/// occurrence exactly as the same turn and move given as an axis, an angle and a translation.
|
||||
#[test]
|
||||
fn matrix_move_turns_a_quarter_and_moves() {
|
||||
let mut s = Session::default();
|
||||
let (_, occ) = created(&mut s, "Block");
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 1, "height": 1}));
|
||||
// 90 degrees about Z, then (10, 20, 30): (x, y, z) goes to (10 - y, 20 + x, 30 + z).
|
||||
let r = run(&mut s, "occurrence.move", json!({"occurrence": occ, "matrix": [0, -1, 0, 10, 1, 0, 0, 20, 0, 0, 1, 30]}));
|
||||
let t = &r["transform"];
|
||||
for (col, expected) in [[0.0, 1.0, 0.0, 0.0], [-1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [10.0, 20.0, 30.0, 1.0]].iter().enumerate() {
|
||||
for (row, x) in expected.iter().enumerate() {
|
||||
near(&t[col][row], *x);
|
||||
}
|
||||
}
|
||||
// The box's centre (1, 0.5, 0.5) lands at (9.5, 21, 30.5).
|
||||
let props = run(&mut s, "occurrence.properties", json!({"occurrence": occ}));
|
||||
for (i, x) in [9.5, 21.0, 30.5].into_iter().enumerate() {
|
||||
near(&props["total"]["center_of_mass"][i], x);
|
||||
}
|
||||
// The same as axis/angle/translate, and the 4x4 form as rows says the same.
|
||||
let mut other = Session::default();
|
||||
let (_, o2) = created(&mut other, "Block");
|
||||
let r2 = run(&mut other, "occurrence.move", json!({"occurrence": o2, "axis": [0, 0, 1], "angle": "90 deg", "translate": [10, 20, 30]}));
|
||||
let mut rows = Session::default();
|
||||
let (_, o3) = created(&mut rows, "Block");
|
||||
let r3 = run(&mut rows, "occurrence.move", json!({"occurrence": o3, "matrix": [[0, -1, 0, 10], [1, 0, 0, 20], [0, 0, 1, 30], [0, 0, 0, 1]]}));
|
||||
for col in 0..4 {
|
||||
for row in 0..4 {
|
||||
near(&r2["transform"][col][row], t[col][row].as_f64().unwrap());
|
||||
near(&r3["transform"][col][row], t[col][row].as_f64().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A rotation that is one only up to rounding is orthonormalised; a scale, a shear or a mirror
|
||||
/// is refused with the reason, and the occurrence stays where it was.
|
||||
#[test]
|
||||
fn matrix_move_orthonormalises_rounding_and_refuses_what_is_not_rigid() {
|
||||
let mut s = Session::default();
|
||||
let (_, occ) = created(&mut s, "Block");
|
||||
let (sin, cos) = 30f64.to_radians().sin_cos();
|
||||
let e = 3e-10;
|
||||
let nearly = json!([cos + e, -sin, 0, 5, sin, cos - e, e, 0, 0, -e, 1.0 + e, 0]);
|
||||
let r = run(&mut s, "occurrence.move", json!({"occurrence": occ, "matrix": nearly, "space": "world"}));
|
||||
let m: solidcraft_doc::Mat = serde_json::from_value(r["transform"].clone()).unwrap();
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
let dot: f64 = (0..3).map(|k| m[i][k] * m[j][k]).sum();
|
||||
assert!((dot - f64::from(u8::from(i == j))).abs() < 1e-15, "column {i}·{j} = {dot}");
|
||||
}
|
||||
}
|
||||
assert!((m[0][0] - cos).abs() < 1e-9 && (m[0][1] - sin).abs() < 1e-9 && (m[3][0] - 5.0).abs() < 1e-12, "{m:?}");
|
||||
|
||||
let before = s.doc.occurrences.iter().find(|o| o.id == occ).unwrap().transform;
|
||||
let refused = |s: &mut Session, p: Value| s.execute("occurrence.move", &p).unwrap_err().to_string();
|
||||
let scaled = refused(&mut s, json!({"occurrence": occ, "matrix": [1.001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]}));
|
||||
assert!(scaled.contains("not rigid") && scaled.contains("2.00"), "{scaled}");
|
||||
let sheared = refused(&mut s, json!({"occurrence": occ, "matrix": [1, 0.01, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]}));
|
||||
assert!(sheared.contains("not rigid"), "{sheared}");
|
||||
let mirrored = refused(&mut s, json!({"occurrence": occ, "matrix": [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]}));
|
||||
assert!(mirrored.contains("solid.mirror"), "{mirrored}");
|
||||
let projective = refused(&mut s, json!({"occurrence": occ, "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.5, 0, 0, 1]}));
|
||||
assert!(projective.contains("fourth row"), "{projective}");
|
||||
// A looser tolerance is the caller's to ask for, up to 1e-3.
|
||||
let rounded = json!([0.866, -0.5, 0, 0, 0.5, 0.866, 0, 0, 0, 0, 1, 0]);
|
||||
assert!(refused(&mut s, json!({"occurrence": occ, "matrix": rounded})).contains("not rigid"));
|
||||
for p in [
|
||||
json!({"occurrence": occ, "matrix": rounded, "tolerance": 1}),
|
||||
json!({"occurrence": occ, "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], "translate": [1, 0, 0]}),
|
||||
json!({"occurrence": occ, "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0], "translate": [1, 0, 0]}),
|
||||
json!({"occurrence": occ, "matrix": [1, 0, 0, 1e12, 0, 1, 0, 0, 0, 0, 1, 0]}),
|
||||
json!({"occurrence": occ, "matrix": [[1, 0, 0, 0], [0, 1, 0, 0]]}),
|
||||
json!({"occurrence": occ, "matrix": "identity"}),
|
||||
json!({"occurrence": occ, "matrix": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}),
|
||||
] {
|
||||
assert!(s.execute("occurrence.move", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == occ).unwrap().transform, before);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": occ, "matrix": rounded, "tolerance": 1e-3}));
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
//! #2424: curves start at the ends of an associated offset spline, and follow them.
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::Document;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||
}
|
||||
fn solved(s: &Session, id: u64) -> Sketch {
|
||||
s.model.state().sketch(id).unwrap().sketch.clone()
|
||||
}
|
||||
/// An open fit-point spline `s1` and its associated offset; returns the offset's id.
|
||||
fn offset_spline(s: &mut Session) -> (u64, String) {
|
||||
run(s, "sketch.create", json!({"plane":"XY"}));
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
run(s, "sketch.spline.fit_point", json!({"points":[[0,0],[10,5],[20,0],[30,5]],"ids":["s1"]}));
|
||||
let result = run(s, "sketch.offset", json!({"curves":["s1"],"distance":"2 mm","chain":true}));
|
||||
(sketch, result["curves"][0].as_str().unwrap().into())
|
||||
}
|
||||
fn coincident(sk: &Sketch, a: usize, b: usize) -> bool {
|
||||
sk.constraints.iter().any(|k| matches!(k.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_drawn_from_offset_spline_ends_get_their_own_coincident_points() {
|
||||
let mut s = Session::default();
|
||||
let (id, output) = offset_spline(&mut s);
|
||||
// Close the shape between the spline and its offset at both ends.
|
||||
run(&mut s, "sketch.line", json!({"points":["s1.start", format!("{output}.start")],"ids":["l1"]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[format!("{output}.end"), "s1.end"],"ids":["l2"]}));
|
||||
let sk = solved(&s, id);
|
||||
sk.validate_freeform_offsets().unwrap();
|
||||
let o = sk.curve_index(&output).unwrap();
|
||||
let (start, end) = sk.curves[o].kind.ends().unwrap();
|
||||
let (l1, l2) = (sk.curve_index("l1").unwrap(), sk.curve_index("l2").unwrap());
|
||||
let (CurveKind::Line { b: l1_end, .. }, CurveKind::Line { a: l2_start, .. }) = (&sk.curves[l1].kind, &sk.curves[l2].kind) else {
|
||||
panic!("lines")
|
||||
};
|
||||
assert!(*l1_end != start && *l2_start != end, "the offset keeps sole ownership of its nodes");
|
||||
assert!(coincident(&sk, start, *l1_end) && coincident(&sk, end, *l2_start));
|
||||
assert!(sk.points[*l1_end].pos.dist(sk.points[start].pos) < 1e-9);
|
||||
|
||||
// Moving the source moves the offset, and the lines follow its ends.
|
||||
let from = sk.point(sk.resolve_point("s1.start").unwrap()).unwrap();
|
||||
run(&mut s, "sketch.drag", json!({"entity":"s1.start","from":[from.x, from.y],"to":[from.x - 3.0, from.y + 2.0]}));
|
||||
let moved = solved(&s, id);
|
||||
moved.validate_freeform_offsets().unwrap();
|
||||
assert!(moved.points[start].pos.dist(sk.points[start].pos) > 1.0, "the offset followed its source");
|
||||
assert!(moved.points[*l1_end].pos.dist(moved.points[start].pos) < 1e-6);
|
||||
assert!(moved.points[*l2_start].pos.dist(moved.points[end].pos) < 1e-6);
|
||||
|
||||
// The design saves and loads with the connection.
|
||||
let doc = Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let loaded = doc.sketch(id).unwrap();
|
||||
loaded.validate_freeform_offsets().unwrap();
|
||||
assert!(coincident(loaded, start, *l1_end));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_arc_from_an_offset_end_is_drawn_and_undone_atomically() {
|
||||
let mut s = Session::default();
|
||||
let (id, output) = offset_spline(&mut s);
|
||||
let before = s.doc.to_json();
|
||||
run(&mut s, "sketch.arc.three_point", json!({"start":format!("{output}.end"),"through":[33,3],"end":"s1.end"}));
|
||||
let sk = solved(&s, id);
|
||||
sk.validate_freeform_offsets().unwrap();
|
||||
let end = sk.curves[sk.curve_index(&output).unwrap()].kind.ends().unwrap().1;
|
||||
assert_eq!(sk.curves.iter().filter(|c| c.kind.uses(end)).count(), 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_offset_spline_shows_only_its_ends() {
|
||||
let mut s = Session::default();
|
||||
let (id, output) = offset_spline(&mut s);
|
||||
let sk = solved(&s, id);
|
||||
let o = sk.curve_index(&output).unwrap();
|
||||
let (start, end) = sk.curves[o].kind.ends().unwrap();
|
||||
let nodes = sk.curves[o].kind.point_ids();
|
||||
assert!(nodes.len() > 2);
|
||||
for i in nodes {
|
||||
assert_eq!(crate::view::sketch_point_visible(&sk, i), i == start || i == end, "node {i}");
|
||||
}
|
||||
}
|
||||
@@ -521,31 +521,3 @@ fn pattern_of_cuts_beside_a_round_needs_no_retries() {
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, expected) < 1e-5, "{v} vs {expected}");
|
||||
}
|
||||
|
||||
/// #2755: a cut cylinder with `targets` cuts only those bodies, and so do its pattern and mirror
|
||||
/// copies: a body over the plate that the cylinder was told to leave alone keeps its volume.
|
||||
#[test]
|
||||
fn copies_of_a_primitive_cut_only_its_targets() {
|
||||
let body_volume = |s: &mut Session, name: &str| {
|
||||
let m = run(s, "inspect.measure", json!({"bodies": [name]}));
|
||||
m["bodies"][0]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
};
|
||||
// The command, and the holes in the plate after it (the cylinder's and its copies).
|
||||
for (copy, holes) in [
|
||||
(json!(["solid.pattern.rectangular", {"features": ["Trou"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 30}]), 3.0),
|
||||
(json!(["solid.mirror", {"features": ["Trou"], "plane": {"origin": [50, 0, 0], "normal": [1, 0, 0]}}]), 2.0),
|
||||
] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 10, "body_name": "Plaque"}));
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 10, "corner": [0, 0, 5], "operation": "new", "body_name": "Voisin"}));
|
||||
let cyl = json!({"radius": 3, "height": 30, "base": [10, 10, -5], "operation": "cut", "targets": ["Plaque"], "name": "Trou"});
|
||||
run(&mut s, "solid.cylinder", cyl);
|
||||
assert!(rel(body_volume(&mut s, "Voisin"), 60000.0) < 1e-9);
|
||||
let (id, p) = (copy[0].as_str().unwrap_or_default(), copy[1].clone());
|
||||
let r = run(&mut s, id, p);
|
||||
assert_eq!(r["changed_bodies"], json!(["Plaque"]), "{id}");
|
||||
assert!(rel(body_volume(&mut s, "Voisin"), 60000.0) < 1e-9, "{id} cut Voisin");
|
||||
let removed = 60000.0 - body_volume(&mut s, "Plaque");
|
||||
assert!(rel(removed, holes * PI * 9.0 * 10.0) < 1e-3, "{id}: {removed} mm³ cut from Plaque");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("entities: [curve, point or constraint ids]"),
|
||||
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).icon("sketch_move").enabled(in_sketch).params("point: ref, to: [x,y]"),
|
||||
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).icon("sketch_move").enabled(in_sketch).live(drag_live).params(
|
||||
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: a point moves toward `to` within the design constraints; when exact cursor placement is impossible, bounded feasible steps minimize cursor distance while keeping design constraints mandatory. A line (spline, conic) slides by to - from; a circle or arc grabbed on its edge takes the radius to `to`. Long moves use continuation to retain the solution branch, and a step that would sweep an arc through its start is rejected. Returns `reached`: progress along the requested move (0..1; feasible point motion projects its actual displacement onto that move), and `at`: where the grabbed spot went (`from` moved as the dragged points did), the `from` of the next move. `live: true` solves the sketch and leaves later features stale until `sketch.drag_end` or a command that reads the model; `dof` is then the one from before the drag",
|
||||
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can and keeps its solution branch (a long move is followed in short steps, each solved with the entity held; a step that cannot be solved that way is approached as far as it can be and the next ones tried; when none past it solves, the last one is left to the sketch's ordinary solve, which may pull it back or leave the sketch unsolved; a step that would sweep an arc through its start is undone and the drag ends there). Returns `reached`: how much of the way from `from` to `to` the geometry followed (0..1), and `at`: where the grabbed spot went (`from` moved as the dragged points did), the `from` of the next move. `live: true` (each frame of an interactive drag) solves the sketch once and leaves the features after it as they were (stale) until `sketch.drag_end` or any command that reads the model; `dof` is then the one from before the drag",
|
||||
),
|
||||
CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).icon("sketch_move").enabled(in_sketch).doc_only().params(
|
||||
"constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom",
|
||||
@@ -275,8 +275,6 @@ pub(super) fn edit_solved<T>(
|
||||
// The stored sketch is solved for its stored values.
|
||||
let stored = sk.driving_values();
|
||||
let (out, left) = f(&mut sk, &mut doc)?;
|
||||
// A curve that took an offset spline's generated node (drawn from its end) gets its own point there.
|
||||
sk.detach_offset_points()?;
|
||||
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
|
||||
let rep = match left {
|
||||
// A drag step solved it (held, with the dimensions' values applied).
|
||||
@@ -1437,7 +1435,7 @@ fn keep_size(sk: &Sketch, e: Ent) -> Vec<ConstraintKind> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Satisfy a just-added constraint the way a dimensional-constraint tool applies it: the first-picked entities stay
|
||||
/// Satisfy a just-added constraint the way Fusion applies it: the first-picked entities stay
|
||||
/// put and only the second moves (first keeping its size, so it turns and slides, then also
|
||||
/// resizing).
|
||||
/// If the second can't move (fixed, projected or fully determined), the first moves instead;
|
||||
|
||||
@@ -166,7 +166,7 @@ fn filleting_a_side_keeps_its_original_midpoint() {
|
||||
/// The spout of a basin: a vertical riser and a horizontal top, the top's length dimensioned
|
||||
/// to the corner and the corner's height dimensioned from the origin. Filleting the corner used
|
||||
/// to drop the height and leave the length on the shortened straight part, so the top came out
|
||||
/// a fillet radius too long. The corner now stays as a virtual sharp corner (a point
|
||||
/// a fillet radius too long. Like Fusion, the corner now stays as a virtual sharp corner (a point
|
||||
/// on both lines' extensions) and the dimensions measure to it.
|
||||
#[test]
|
||||
fn a_fillet_keeps_the_dimensions_of_its_corner_on_a_virtual_sharp() {
|
||||
|
||||
@@ -104,31 +104,29 @@ impl Path {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag: the grabbed entity follows the cursor where possible (a point moves toward it; a line or spline
|
||||
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line or spline
|
||||
/// slides by the cursor's move; a circle or arc grabbed on its edge changes radius about its
|
||||
/// centre; a conic grabbed on its curve reads its fullness, `rho`, off the cursor) and the rest
|
||||
/// of the sketch moves as little as it can, staying on the
|
||||
/// solution it started on. Fixed geometry stays. A step that cannot be solved with the entity
|
||||
/// held (the path crosses where the geometry cannot reach) is approached by halving the way to
|
||||
/// it, and the next steps are tried from the last solved state, the last of them being the whole
|
||||
/// remaining move. A point whose exact pin fails instead minimizes cursor distance in the
|
||||
/// design's remaining freedom, retaining only corrected, feasible poses. For other entities,
|
||||
/// when none past it solves, the first failed step stays placed but unsolved:
|
||||
/// remaining move. When none past it solves, the first failed step stays placed but unsolved:
|
||||
/// the edit's ordinary, non-strict solve that follows (seeded on the last solved state, see
|
||||
/// [`Left::MovedFrom`]) then pulls it back (fully constrained geometry) or leaves the sketch
|
||||
/// unsolved for the user to see. A step that would sweep an arc through its own start is undone
|
||||
/// and the drag ends there. Returns how far it got and what it leaves for the edit's solve
|
||||
/// ([`Left::Solved`]: the held solve of the last step it kept left the sketch solved).
|
||||
///
|
||||
/// `live`: one frame of an interactive drag, whose held solves and cursor correction share
|
||||
/// one frame's work budget ([`Budget::frame`]); otherwise each has a solve's own.
|
||||
/// `live`: one frame of an interactive drag, whose held solves share one frame's decomposition
|
||||
/// budget ([`Budget::frame`]); otherwise each has a solve's own.
|
||||
pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2, live: bool) -> (Dragged, Left) {
|
||||
let Some((path, hold, n, short)) = prepare(sk, e, from, to) else {
|
||||
return (Dragged { reached: 0.0, moving: Vec::new() }, Left::Unsolved);
|
||||
};
|
||||
#[cfg(test)]
|
||||
let (n, short) = (n.min(step_cap::get()), short.min(step_cap::get()));
|
||||
let moving: Vec<_> = path.points().filter_map(|q| Some((q, sk.point(q)?))).collect();
|
||||
let moving = path.points().filter_map(|q| Some((q, sk.point(q)?))).collect();
|
||||
let mut frame = live.then(frame_budget);
|
||||
// Only success matters to these solves: the degrees of freedom of a sketch with the entity
|
||||
// held are not the sketch's. Each is seeded on the last solved state (`last`), so what the
|
||||
@@ -213,41 +211,6 @@ pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2, live: b
|
||||
failed = Some(t);
|
||||
}
|
||||
}
|
||||
// First retain held continuation across unreachable stretches of an otherwise reachable
|
||||
// path (for example an arm dragged through the hole around its shoulder). If it cannot
|
||||
// arrive, optimize the full cursor target without relaxing the design constraints.
|
||||
if failed.is_some()
|
||||
&& let Ent::Point(q) = e
|
||||
{
|
||||
let (points, radii) = sk.coupled(&hold.points, &hold.radii);
|
||||
let size = smallest_size(sk, &hold, Some(Group { points: &points, radii: &radii }));
|
||||
let max_step = (size * SHORT_STEP_FRACTION).min(path.length()).max(MIN_STEP);
|
||||
#[cfg(test)]
|
||||
let spent = frame.as_ref().map_or(0, Budget::spent);
|
||||
let followed = solidcraft_sketch::move_point_towards(sk, q, to, max_step, frame.as_mut(), |before, after| {
|
||||
let mut geometry = Geometry::default();
|
||||
geometry.take(before);
|
||||
!geometry.sweep_wrapped(after)
|
||||
});
|
||||
#[cfg(test)]
|
||||
if let Some(budget) = &frame {
|
||||
solves.work += budget.spent() - spent;
|
||||
solves.cut |= budget.exhausted();
|
||||
}
|
||||
if followed {
|
||||
#[cfg(test)]
|
||||
last_drag::set(solves);
|
||||
let reached = moving
|
||||
.iter()
|
||||
.find(|(p, _)| *p == q)
|
||||
.and_then(|(_, start)| {
|
||||
let d = to - *start;
|
||||
sk.point(q).map(|at| if at.dist(to) < 1e-6 || d.dot(d) == 0.0 { 1.0 } else { ((at - *start).dot(d) / d.dot(d)).clamp(0.0, 1.0) })
|
||||
})
|
||||
.unwrap_or(0.0);
|
||||
return (Dragged { reached, moving }, Left::Solved);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
last_drag::set(solves);
|
||||
let left = match failed {
|
||||
@@ -262,8 +225,8 @@ pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2, live: b
|
||||
(Dragged { reached: good, moving }, left)
|
||||
}
|
||||
|
||||
/// The held solves of one drag: how many, their shared work (including cursor correction),
|
||||
/// and whether it ran out of budget.
|
||||
/// The held solves of one drag: how many, the decomposition work they did, and whether one ran
|
||||
/// out of budget.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(super) struct Solves {
|
||||
pub count: usize,
|
||||
|
||||
@@ -257,31 +257,6 @@ fn tie(sk: &mut Sketch, pieces: &[usize]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dimensions and relations that measure the whole of curve `ci` (a line's length, an equal
|
||||
/// length, its midpoint, an arc's length or sweep) would stay on the piece that keeps its id
|
||||
/// after a trim and stretch that piece back to the old size, moving the rest of the sketch. A
|
||||
/// length becomes the distance between the curve's old ends, as a fillet keeps it to its virtual
|
||||
/// corner (it goes with an end the trim removes); the others go.
|
||||
fn release_extent(sk: &mut Sketch, ci: usize) {
|
||||
let ends = match sk.curves.get(ci).map(|c| &c.kind) {
|
||||
Some(CurveKind::Line { a, b }) => Some((*a, *b)),
|
||||
_ => None,
|
||||
};
|
||||
sk.constraints.retain_mut(|k| match k.kind {
|
||||
ConstraintKind::Length { l, value } if l == ci => match ends {
|
||||
Some((p, q)) => {
|
||||
k.kind = ConstraintKind::Distance { p, q, value };
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
ConstraintKind::Equal { a, b } if ends.is_some() && (a == ci || b == ci) => false,
|
||||
ConstraintKind::Midpoint { l, .. } if l == ci => false,
|
||||
ConstraintKind::ArcLength { c, .. } | ConstraintKind::ArcSweep { c, .. } if c == ci => false,
|
||||
_ => true,
|
||||
});
|
||||
}
|
||||
|
||||
/// The cut parameters bracketing `tq` on curve `ci`: (before, after) with their cutter curves.
|
||||
fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
|
||||
let Some(sh) = sk.shape(ci) else { return (Vec::new(), false) };
|
||||
@@ -330,7 +305,6 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
doc.prune_model_params();
|
||||
return Ok(json!({"deleted": id}));
|
||||
}
|
||||
release_extent(sk, ci);
|
||||
let ts: Vec<f64> = cuts.iter().map(|c| c.0).collect();
|
||||
let (pieces, pts) = split_at(sk, ci, &ts)?;
|
||||
// The piece containing tq.
|
||||
@@ -664,7 +638,7 @@ struct CornerKept {
|
||||
/// when anything measures to it: a constraint or dimension on the point, the length of either
|
||||
/// line, or it is the origin or still ends another curve. It stays as a virtual sharp corner,
|
||||
/// held on both curves' extensions (their intersection), and the line lengths become distances
|
||||
/// from the far end to it, so the part keeps its size. Otherwise None: the
|
||||
/// from the far end to it, so the part keeps its size, as in Fusion. Otherwise None: the
|
||||
/// caller removes the point.
|
||||
fn keep_corner(sk: &mut Sketch, c: usize, [(ca, na), (cb, nb)]: [(usize, usize); 2]) -> Result<Option<CornerKept>> {
|
||||
let used = sk.curves.iter().any(|x| uses(x, c));
|
||||
|
||||
@@ -1585,37 +1585,3 @@ fn offset_driver_format18_offsets_keep_fixed_distances_on_upgrade() {
|
||||
assert!(!sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::OffsetDistance { .. })));
|
||||
assert_eq!(ids(&result["curves"]).len(), sk.offsets[0].outputs.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_keeps_the_rest_of_a_dimensioned_line_where_it_was() {
|
||||
// A 40 x 40 square, its right side dimensioned with the Dimension tool (a line length).
|
||||
let mut s = new_sketch();
|
||||
let sq = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 40], [0, 40]], "closed": true, "infer": true}))["curves"]);
|
||||
let (right, top) = (sq[1].clone(), sq[2].clone());
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [right]}));
|
||||
// A step drawn across the top right corner, its ends on the top and right sides.
|
||||
let step = ids(&run(&mut s, "sketch.line", json!({"points": [[23, 40], [23, 23], [40, 23]], "infer": true}))["curves"]);
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", step[0]), "b": top}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.end", step[1]), "b": right}));
|
||||
let corner = |s: &Session| {
|
||||
let sk = sketch(s);
|
||||
sk.point(sk.resolve_point(&format!("{}.end", step[1])).unwrap()).unwrap()
|
||||
};
|
||||
let forty = |s: &Session| {
|
||||
sketch(s).constraints.iter().filter(|c| c.kind.value().is_some_and(|v| close(v, 40.0))).map(|c| c.kind.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
// Trim the right side above the step: the 40 stays between the square's corners, which are
|
||||
// both still there, instead of stretching the 23 mm that is left back to 40.
|
||||
run(&mut s, "sketch.trim", json!({"curve": right, "at": [40, 30]}));
|
||||
assert!(corner(&s).dist(Vec2::new(40.0, 23.0)) < 1e-6, "the step moved to {:?}", corner(&s));
|
||||
let sk = sketch(&s);
|
||||
let ends = sk.shape(sk.curve_index(&right).unwrap()).unwrap();
|
||||
assert!(matches!(ends, solidcraft_sketch::Shape::Line { a, b } if close(a.dist(b), 23.0)), "{ends:?}");
|
||||
assert!(matches!(forty(&s)[..], [ConstraintKind::Distance { .. }]), "{:?}", forty(&s));
|
||||
|
||||
// Trim the top side right of the step: the corner the 40 measured to goes, and the 40 with it.
|
||||
run(&mut s, "sketch.trim", json!({"curve": top, "at": [30, 40]}));
|
||||
assert!(corner(&s).dist(Vec2::new(40.0, 23.0)) < 1e-6, "the step moved to {:?}", corner(&s));
|
||||
assert!(forty(&s).is_empty(), "{:?}", forty(&s));
|
||||
}
|
||||
|
||||
@@ -550,7 +550,7 @@ pub(super) fn project_internal_faces(s: &mut Session, id: u64, body: &str, indic
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
/// Project the edges of the face a new sketch was created on (the default).
|
||||
/// Project the edges of the face a new sketch was created on (Fusion's default).
|
||||
pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result<Option<String>> {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let Some(anchor) = doc
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user