mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 11:35:55 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd45fc5fe6 | |||
| 355154fa9b | |||
| f1a5f4ab30 | |||
| 3a9b053f5b | |||
| aaa35a94d8 | |||
| 852d364d5f | |||
| 34cb3167b6 | |||
| 1666ca7018 | |||
| 768e69c4c7 | |||
| 5997d28de7 | |||
| 5122b98af3 | |||
| f9342aa8b6 | |||
| d9c3fdb80f | |||
| dc27988bc2 | |||
| e3831b05d4 | |||
| 59ba87103e | |||
| 19ffb0dd75 | |||
| c66e33284b | |||
| 57d210130c | |||
| 71534117af | |||
| 38f6ed76c9 | |||
| 66108a6b99 | |||
| 8e762fee57 | |||
| 6b1fc16d47 | |||
| 1ea85ff231 | |||
| 2aa808b116 | |||
| aa438611a7 | |||
| 86b2b97d6f | |||
| c177adfceb | |||
| 7ad5130cd2 | |||
| a3080d02d6 | |||
| 17e173cb7b | |||
| 46bc44b706 | |||
| 9eb1540a34 | |||
| 3a1e1eed39 | |||
| 9ee2de9a0f | |||
| ed64988d8f | |||
| 03e6c3d468 | |||
| cbf589eb8c | |||
| cd85efd73d | |||
| 2452513b65 | |||
| 9c411859bc | |||
| 22a86a744e | |||
| 143435dc1c | |||
| 36aa4a15fe | |||
| e887792182 | |||
| a940f77447 | |||
| c59842b8e1 | |||
| a587d3339e |
+31
-2
@@ -1,8 +1,9 @@
|
||||
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
|
||||
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
|
||||
# CI builds the tests once and runs them in shards: 2 for a pull request, 4 for main and the nightly
|
||||
# (.github/workflows/ci.yml).
|
||||
|
||||
[profile.ci]
|
||||
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
|
||||
# Heavy tests (one to twelve minutes each on a CI runner) that pull requests skip. They run on
|
||||
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`. The
|
||||
# boolean_props property tests run only nightly (ci.yml excludes them from pushes to main).
|
||||
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
|
||||
@@ -17,6 +18,7 @@ not (
|
||||
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
|
||||
))
|
||||
| (package(solidcraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
|
||||
| (package(solidcraft-kernel) & test(=tspline::tests::hostile_tsplines_never_panic))
|
||||
| (package(solidcraft-kernel) & binary(boolean_props))
|
||||
| (package(solidcraft-engine) & binary(atomic_commands))
|
||||
| (package(solidcraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
|
||||
@@ -39,3 +41,30 @@ path = "junit.xml"
|
||||
filter = "binary(action_matrix) & test(=action_matrix_sample)"
|
||||
threads-required = "num-cpus"
|
||||
|
||||
|
||||
# The slowest tests start first, so a shard does not end with one of them running alone on a
|
||||
# core while the others sit idle (seed_81 alone takes about 20 minutes under CI load). Medians
|
||||
# from the CI test timings (the junit artifacts), slowest first; a test over ~100 s belongs here.
|
||||
[[profile.ci.overrides]]
|
||||
filter = """
|
||||
(package(solidcraft-engine) & (
|
||||
test(/^tests::robustness_goldens::seed_(81|45|20|24|95|02|57|40|18|48|32|46|85|61)$/)
|
||||
| test(/^tests::robustness::/)
|
||||
| test(=tests::hostile_params_never_panic)
|
||||
| test(=cmd::clearance::tests::distances_are_exact_on_edges_and_curved_faces)
|
||||
| test(=cmd::clipboard::tests::a_pasted_coil_joins_the_pasted_body)
|
||||
| test(=tests::pleat_join::the_pleats_join_at_every_wall_from_2_4_to_2_6_mm)
|
||||
| test(=tests::flush_join_loft::a_flange_flush_with_the_end_of_a_bent_tapered_tube_joins)
|
||||
| test(=cmd::sheet::tests::perforated_sheet_takes_a_thousand_round_holes)
|
||||
| test(=boolean_regression_tests::joined_loft_frames_plan_their_display_at_every_quality)
|
||||
))
|
||||
| (package(solidcraft-engine) & (binary(atomic_commands) | binary(step_export_regressions)))
|
||||
| (package(solidcraft-kernel) & (
|
||||
test(=curveblend::tests::rolling_ball_turns_about_a_rounded_corner_of_its_radius)
|
||||
| test(=tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole)
|
||||
| test(=tspline::tests::hostile_tsplines_never_panic)
|
||||
| test(=fill::tests::regular_rims_of_three_to_eight_sides_fill_with_one_face_and_close_the_cup)
|
||||
| test(/^issue_788_tests::equal_bores_crossing_/)
|
||||
))
|
||||
"""
|
||||
priority = 100
|
||||
|
||||
+133
-48
@@ -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 4 shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# Tests are built once and run in shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
|
||||
# and app startup) run on pull requests only when what they test changes (the GPU crate, the app,
|
||||
# the viewport's GPU glue, the graphics dependencies, their scripts or this file); main and
|
||||
@@ -14,26 +14,37 @@
|
||||
# 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), 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.
|
||||
# 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 GitHub's runners, so it never holds up the queue. 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).
|
||||
# Where the test shards run on GitHub'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.
|
||||
# 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.
|
||||
# marked ready for review, and on every push after that. `labeled` is there for approved-for-ci
|
||||
# only (other labels are skipped, see the top of this file).
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
types: [opened, synchronize, reopened, ready_for_review, labeled]
|
||||
# The merge queue: each queued group runs the full suite before it lands on main.
|
||||
merge_group:
|
||||
push:
|
||||
@@ -53,7 +64,10 @@ permissions:
|
||||
concurrency:
|
||||
# A new PR push replaces the old run. Only one run per group may wait, so a busy main would
|
||||
# cancel the waiting nightly (or a Run workflow) before it starts: they get groups of their own.
|
||||
group: ci-${{ github.event_name }}-${{ github.ref }}
|
||||
# A `labeled` event for any label but approved-for-ci does no work and must not cancel (or queue
|
||||
# behind) the PR's real run, so it gets a group of its own; approved-for-ci replaces the
|
||||
# unlabelled run with the full one.
|
||||
group: ci-${{ github.event_name }}-${{ github.ref }}${{ github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci' && format('-ignored-{0}', github.run_id) || '' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
@@ -75,8 +89,9 @@ jobs:
|
||||
|
||||
changes:
|
||||
name: Select checks
|
||||
# Not on a draft: every other check needs this one, so they skip too.
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
# 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')
|
||||
# Trusted runs (see the top of this file) use the self-hosted runners.
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
permissions:
|
||||
@@ -89,12 +104,13 @@ jobs:
|
||||
goldens: ${{ steps.paths.outputs.goldens }}
|
||||
windows: ${{ steps.paths.outputs.windows }}
|
||||
full: ${{ steps.paths.outputs.full }}
|
||||
# Code changed on a pull request that has no approved-for-ci label yet: only Quick compile.
|
||||
quick: ${{ steps.paths.outputs.quick }}
|
||||
packages: ${{ steps.paths.outputs.packages }}
|
||||
oracle: ${{ steps.paths.outputs.oracle }}
|
||||
# Whether the other Linux jobs use the self-hosted runners too.
|
||||
# Trusted self-hosted runs only: a fork PR's checks run on the untrusted pool and must never
|
||||
# route the jobs after them to the trusted runners.
|
||||
local: ${{ runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
|
||||
# The fast self-hosted runners are kept for main and the merge queue (the pre-merge gate);
|
||||
# pull requests, same-repo ones included, take the GitHub-hosted or untrusted paths.
|
||||
local: ${{ runner.environment == 'self-hosted' && github.event_name != 'pull_request' }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -107,6 +123,7 @@ 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
|
||||
@@ -183,6 +200,15 @@ 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"
|
||||
@@ -190,13 +216,14 @@ 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
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -234,6 +261,26 @@ 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' || 'ubuntu-24.04' }}
|
||||
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: runner.environment == 'github-hosted'
|
||||
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
|
||||
@@ -268,7 +315,9 @@ jobs:
|
||||
build-tests:
|
||||
name: Build tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
# 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: ${{ (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 }}
|
||||
@@ -315,6 +364,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' || ((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:
|
||||
@@ -339,39 +389,63 @@ jobs:
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
# 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
|
||||
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).
|
||||
all=()
|
||||
# The boolean property tests draw new random inputs every run; they run only in the
|
||||
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
|
||||
filter=()
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
all=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
|
||||
# The boolean property tests draw new random inputs every run; they run only in the
|
||||
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
|
||||
filter=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
|
||||
elif [ "$GOLDENS" = true ]; then
|
||||
# A pull request that can change a robustness golden (tests/robustness/goldens.json)
|
||||
# also checks every seed but the slow ones, in the same run so they share its cores.
|
||||
filter=(--ignore-default-filter -E "default() | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))")
|
||||
fi
|
||||
# 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)"
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
- name: Upload test timings
|
||||
@@ -379,7 +453,7 @@ jobs:
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: junit-${{ matrix.shard }}
|
||||
path: target/nextest/ci/junit.xml
|
||||
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
action-matrix:
|
||||
@@ -627,8 +701,11 @@ jobs:
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
|
||||
# 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' || 'ubuntu-24.04' }}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
@@ -636,6 +713,9 @@ jobs:
|
||||
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 }}
|
||||
@@ -648,9 +728,14 @@ jobs:
|
||||
run: |
|
||||
test "$CHANGES" = success
|
||||
test "$CHECKS" = success
|
||||
# An unapproved pull request: the heavy jobs are skipped on purpose.
|
||||
if [ "$QUICK" = true ]; then
|
||||
test "$QUICK_COMPILE" = success
|
||||
fi
|
||||
if [ "$NATIVE" = true ]; then
|
||||
test "$CLIPPY" = success
|
||||
test "$BUILD" = success
|
||||
# The self-hosted test shards build their own tests (no Build tests job there).
|
||||
[ "$BUILD" = success ] || { [ "$LOCAL" = true ] && [ "$BUILD" = skipped ]; }
|
||||
test "$TESTS" = success
|
||||
test "$DOCS" = success
|
||||
fi
|
||||
|
||||
@@ -96,6 +96,7 @@ 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 |
|
||||
|
||||
+21
-3
@@ -191,17 +191,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 four minutes each), which run in full validation
|
||||
Pull requests skip a few heavy tests (one to twelve minutes each), which run in full validation
|
||||
(the merge queue, nightly, manual runs and pushes to `main` not already tested by the queue).
|
||||
Check that validation after merging. They are listed in
|
||||
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
|
||||
|
||||
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
|
||||
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all seeds),
|
||||
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all but the slowest, `SLOW_SEEDS` in `ci.yml`),
|
||||
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
|
||||
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
|
||||
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, and the `boolean_props` property tests
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, `tspline::tests::hostile_tsplines_never_panic`, and the `boolean_props` property tests
|
||||
(these run only in the nightly "Property tests" job: their inputs are random, so main skips them too)
|
||||
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
|
||||
|
||||
@@ -285,6 +285,10 @@ 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).
|
||||
|
||||
@@ -359,6 +363,20 @@ 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.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4109,11 +4109,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -4124,7 +4124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4133,7 +4133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4150,7 +4150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4167,7 +4167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4176,7 +4176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4193,7 +4193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4213,7 +4213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4233,7 +4233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4242,7 +4242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4256,7 +4256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4267,11 +4267,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4296,7 +4296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5826,7 +5826,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
+4
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.1"
|
||||
version = "0.16.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -123,6 +123,9 @@ incremental = false
|
||||
|
||||
[profile.ci.package."*"]
|
||||
opt-level = 2
|
||||
# No line tables for dependencies: they were over a third of the test archive every CI shard
|
||||
# downloads. Our own crates keep theirs, so a backtrace still names file:line in them.
|
||||
debug = false
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -311,7 +311,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Coming from Autodesk Fusion? SolidCraft uses familiar parametric CAD workflows: constrained
|
||||
SolidCraft uses a familiar parametric CAD workflow: constrained
|
||||
sketches, solid features, an editable timeline, parameters and assemblies. See the
|
||||
[roadmap](ROADMAP.md) for supported operations and current limitations.
|
||||
|
||||
|
||||
+5
-1
@@ -14,6 +14,10 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||
|
||||
Optional GPU viewport ambient occlusion is available in View settings and Graphics preferences,
|
||||
with persistent settings, Performance/Quality graphics presets (Quality combines AO, Fine tessellation and adaptive 2× supersampling; manual combinations show Custom), and the
|
||||
`view.ambient_occlusion` and `view.graphics_preset` commands. See [display settings](docs/ambient-occlusion.md).
|
||||
|
||||
## Scope (owner decision, 2026-10-07)
|
||||
|
||||
The issue batch adds preservation of blind pockets in through cuts, associative projected
|
||||
@@ -55,7 +59,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 in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
|
||||
@@ -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 Fusion's do); M0 turned them the other way",
|
||||
"partial revolves turn toward the sketch normal since ac43365 (as most parametric CAD tools do); M0 turned them the other way",
|
||||
),
|
||||
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
|
||||
];
|
||||
|
||||
@@ -179,7 +179,8 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
}
|
||||
}
|
||||
let all: Vec<&str> = all.iter().map(String::as_str).collect();
|
||||
let future = file_parent.clone().add_filter(label, &all).pick_file();
|
||||
// Any other file attaches by reference, so the list is not the only choice.
|
||||
let future = file_parent.clone().add_filter(label, &all).add_filter("All files", &["*"]).pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
pick_program: Some(Box::new(move || {
|
||||
|
||||
@@ -362,25 +362,31 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
fn start_driver_client(ctx: &Context, shared: &Arc<Mutex<Reading>>) {
|
||||
use solidcraft_3dx_client::Event;
|
||||
let (ctx, shared) = (ctx.clone(), Arc::clone(shared));
|
||||
let started = solidcraft_3dx_client::start("SolidCraft", move |event| match event {
|
||||
Event::DeviceAdded => DRIVER_CONNECTED.store(true, Ordering::Relaxed),
|
||||
Event::DeviceRemoved => {
|
||||
DRIVER_CONNECTED.store(false, Ordering::Relaxed);
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
Event::Axes { translation, rotation } => {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
let now = Instant::now();
|
||||
reading.axes = Axes { translation, rotation };
|
||||
reading.at = [now; 6];
|
||||
ctx.request_repaint();
|
||||
let started = solidcraft_3dx_client::start(
|
||||
"SolidCraft",
|
||||
move |event| match event {
|
||||
Event::DeviceAdded => DRIVER_CONNECTED.store(true, Ordering::Relaxed),
|
||||
Event::DeviceRemoved => {
|
||||
DRIVER_CONNECTED.store(false, Ordering::Relaxed);
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
}
|
||||
Event::Buttons(_) => {}
|
||||
});
|
||||
match started {
|
||||
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
|
||||
Err(error) => log::info!("3D mouse: no driver client: {error}"),
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,15 @@ pub(crate) fn pin_icon(p: &egui::Painter, r: Rect, on: bool, col: Color32) {
|
||||
p.line_segment([at(0.0, -2.0), at(0.0, 7.0)], stroke);
|
||||
}
|
||||
|
||||
/// A breadcrumb separator ("Sketch › Create"): a thin chevron about 3 by 6 px, centred on `c`.
|
||||
/// Drawn rather than typed, because a font's "›" sits on the baseline in some languages
|
||||
/// (Japanese), where it reads as a comma.
|
||||
pub(crate) fn breadcrumb(p: &egui::Painter, c: egui::Pos2, col: Color32) {
|
||||
let s = Stroke::new(1.0, col);
|
||||
p.line_segment([pos2(c.x - 1.5, c.y - 3.0), pos2(c.x + 1.5, c.y)], s);
|
||||
p.line_segment([pos2(c.x + 1.5, c.y), pos2(c.x - 1.5, c.y + 3.0)], s);
|
||||
}
|
||||
|
||||
pub(crate) fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) {
|
||||
p.add(egui::Shape::convex_polygon(vec![pos2(c.x - 3.5, c.y - 2.0), pos2(c.x + 3.5, c.y - 2.0), pos2(c.x, c.y + 2.5)], col, Stroke::NONE));
|
||||
}
|
||||
|
||||
@@ -1337,6 +1337,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Vyberte tělesa k uložení",
|
||||
"ui.save_mesh_temp_failed": "Nelze vytvořit {folder}: {error}",
|
||||
"ui.saved_to": "Uloženo {path}",
|
||||
"ui.sbox_pin": "Připnout nahoru ve vyhledávání S",
|
||||
"ui.sbox_unpin": "Odepnout",
|
||||
"ui.scale": "Měřítko",
|
||||
"ui.section_analysis": "Analýza řezu",
|
||||
"ui.select": "Vybrat",
|
||||
|
||||
@@ -425,8 +425,10 @@
|
||||
"cmd.timeline_roll_to": "Verlaufsmarkierung verschieben",
|
||||
"cmd.timeline_rollback": "Zeitleistenmarkierung verschieben",
|
||||
"cmd.timeline_suppress": "Merkmal unterdrücken",
|
||||
"cmd.view_ambient_occlusion": "Umgebungsverdeckung",
|
||||
"cmd.view_apply": "Benannte Ansicht wiederherstellen",
|
||||
"cmd.view_delete": "Benannte Ansicht löschen",
|
||||
"cmd.view_graphics_preset": "Grafikprofil",
|
||||
"cmd.view_list": "Benannte Ansichten auflisten",
|
||||
"cmd.view_navigate": "Ansicht navigieren",
|
||||
"cmd.view_rename": "Benannte Ansicht umbenennen",
|
||||
@@ -756,6 +758,8 @@
|
||||
"ui.ai_toolbar_button_note": "Die KI-Schaltfläche am rechten Ende der Werkzeugleiste. Strg+Umschalt+A und das Ansicht-Menü öffnen das Panel weiterhin.",
|
||||
"ui.ai_undo_tip": "Alles, was der Agent für diese Nachricht geändert hat, in einem Schritt rückgängig machen",
|
||||
"ui.all": "Alle",
|
||||
"ui.ambient_occlusion": "Umgebungsverdeckung",
|
||||
"ui.ambient_occlusion_help": "Sanfte Schattierung an Kontaktstellen und in Vertiefungen undurchsichtiger Flächen. Erfordert die GPU-Ansicht.",
|
||||
"ui.analysis": "Analyse",
|
||||
"ui.angle": "Winkel",
|
||||
"ui.angle_decimals": "Winkel-Dezimalstellen",
|
||||
@@ -910,6 +914,11 @@
|
||||
"ui.fully_constrained": "Vollständig bestimmt",
|
||||
"ui.general": "Allgemein",
|
||||
"ui.graphics": "Grafik",
|
||||
"ui.graphics_custom": "Benutzerdefiniert",
|
||||
"ui.graphics_performance": "Leistung",
|
||||
"ui.graphics_preset": "Grafikprofil",
|
||||
"ui.graphics_preset_help": "Qualität: Umgebungsverdeckung, feine Anzeigequalität und adaptives 2× Supersampling. Leistung: keine Umgebungsverdeckung, normale Anzeigequalität und kein Supersampling. Manuelle Änderungen: Benutzerdefiniert. MSAA bleibt unverändert.",
|
||||
"ui.graphics_quality": "Qualität",
|
||||
"ui.groupSelected": "Auswahl gruppieren",
|
||||
"ui.heading_assemble": "ZUSAMMENBAU",
|
||||
"ui.heading_box": "BLOCK",
|
||||
@@ -1118,6 +1127,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Wähle die Körper zum Speichern aus",
|
||||
"ui.save_mesh_temp_failed": "{folder} konnte nicht angelegt werden: {error}",
|
||||
"ui.saved_to": "Gespeichert: {path}",
|
||||
"ui.sbox_pin": "Oben im S-Feld anheften",
|
||||
"ui.sbox_unpin": "Lösen",
|
||||
"ui.scale": "Skalieren",
|
||||
"ui.second_rail": "Zweite Leitkurve",
|
||||
"ui.section_analysis": "Querschnittsanalyse",
|
||||
|
||||
+22
-2
@@ -350,6 +350,7 @@
|
||||
"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",
|
||||
@@ -632,8 +633,10 @@
|
||||
"cmd.timeline_roll_to": "Roll History Marker",
|
||||
"cmd.timeline_rollback": "Move Timeline Marker",
|
||||
"cmd.timeline_suppress": "Suppress Feature",
|
||||
"cmd.view_ambient_occlusion": "Ambient Occlusion",
|
||||
"cmd.view_apply": "Restore Named View",
|
||||
"cmd.view_delete": "Delete Named View",
|
||||
"cmd.view_graphics_preset": "Graphics Preset",
|
||||
"cmd.view_list": "List Named Views",
|
||||
"cmd.view_navigate": "Navigate View",
|
||||
"cmd.view_rename": "Rename Named View",
|
||||
@@ -857,7 +860,7 @@
|
||||
"ui.agent_chat_approval": "Allow the agent to do this?",
|
||||
"ui.agent_chat_attach_view": "Attach view",
|
||||
"ui.agent_chat_attach_view_tip": "Send a picture of the current view with the message",
|
||||
"ui.agent_chat_attachable": "Pictures, text and CAD files",
|
||||
"ui.agent_chat_attachable": "Pictures, PDFs, text and CAD files",
|
||||
"ui.agent_chat_auth_agent": "Sign in with {name}",
|
||||
"ui.agent_chat_auth_env": "Set {vars} in your environment and restart SolidCraft.",
|
||||
"ui.agent_chat_auth_needed": "The agent asks you to sign in. You sign in with the agent itself; SolidCraft never sees your password or keys.",
|
||||
@@ -889,7 +892,6 @@
|
||||
"ui.agent_chat_file_error": "{name}: {error}",
|
||||
"ui.agent_chat_file_heic": "{name}: HEIC pictures can't be read here; export it as JPEG or PNG first",
|
||||
"ui.agent_chat_file_importing": "Importing into the design…",
|
||||
"ui.agent_chat_file_kinds": "{name}: pictures (PNG, JPEG, GIF, WebP, BMP, TIFF), text files (SVG too) and CAD files (STEP, IGES, FreeCAD, Rhino, STL, 3MF, OBJ, glTF) can be attached",
|
||||
"ui.agent_chat_file_loading": "Reading the file…",
|
||||
"ui.agent_chat_file_not_a_file": "{name}: not a file",
|
||||
"ui.agent_chat_file_not_a_picture": "{name}: not a picture",
|
||||
@@ -1036,6 +1038,8 @@
|
||||
"ui.ai_toolbar_button_note": "The AI button at the right end of the toolbar. Ctrl+Shift+A and the View menu still open the panel.",
|
||||
"ui.ai_undo_tip": "Undo everything the agent changed for this message, as one step",
|
||||
"ui.all": "All",
|
||||
"ui.ambient_occlusion": "Ambient occlusion",
|
||||
"ui.ambient_occlusion_help": "Soft shading where opaque surfaces meet. Requires the GPU viewport.",
|
||||
"ui.analysis": "Analysis",
|
||||
"ui.angle": "Angle",
|
||||
"ui.angle_decimals": "Angle decimals",
|
||||
@@ -1196,6 +1200,11 @@
|
||||
"ui.fully_constrained": "Fully constrained",
|
||||
"ui.general": "General",
|
||||
"ui.graphics": "Graphics",
|
||||
"ui.graphics_custom": "Custom",
|
||||
"ui.graphics_performance": "Performance",
|
||||
"ui.graphics_preset": "Graphics Preset",
|
||||
"ui.graphics_preset_help": "Quality: ambient occlusion, Fine display quality and adaptive 2× supersampling. Performance: no ambient occlusion, Normal display quality and supersampling off. Manual changes show Custom. MSAA stays unchanged.",
|
||||
"ui.graphics_quality": "Quality",
|
||||
"ui.grid_follows_view": "Grid follows the view",
|
||||
"ui.grid_follows_view_hint": "Side views show the grid on the upright plane facing them instead of the ground",
|
||||
"ui.ground_shadow": "Ground shadow",
|
||||
@@ -1337,6 +1346,10 @@
|
||||
"ui.newSketchGroup": "New Sketch Group",
|
||||
"ui.new_body": "New Body",
|
||||
"ui.new_design": "New Design",
|
||||
"ui.new_extrude_distance": "New extrude distance",
|
||||
"ui.new_extrude_distance_fixed": "Fixed (10 mm)",
|
||||
"ui.new_extrude_distance_last_used": "Last used",
|
||||
"ui.new_extrude_distance_tip": "The distance a new Extrude starts with. Editing an existing extrude keeps its own.",
|
||||
"ui.no_slicer_found": "No slicer found",
|
||||
"ui.normal": "Normal",
|
||||
"ui.normalConstruction": "Normal / Construction",
|
||||
@@ -1400,6 +1413,11 @@
|
||||
"ui.rectangle": "Rectangle",
|
||||
"ui.rectangular_pattern": "Rectangular Pattern",
|
||||
"ui.redo": "Redo",
|
||||
"ui.relink_design_changed": "The assembly changed while choosing a source. Start Relink Source again.",
|
||||
"ui.relink_done": "Source relinked for all instances.",
|
||||
"ui.relink_not_linked": "This component is not linked to a source design.",
|
||||
"ui.relink_select_one": "Select one linked component or one of its instances first.",
|
||||
"ui.relink_source_all": "Relink Source (All Instances)…",
|
||||
"ui.removeFromToolbar": "Remove from Toolbar",
|
||||
"ui.removeSection": "Remove Section",
|
||||
"ui.render": "Render",
|
||||
@@ -1423,6 +1441,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Select the bodies to save",
|
||||
"ui.save_mesh_temp_failed": "Couldn't make {folder}: {error}",
|
||||
"ui.saved_to": "Saved {path}",
|
||||
"ui.sbox_pin": "Pin to the top of the S box",
|
||||
"ui.sbox_unpin": "Unpin",
|
||||
"ui.scale": "Scale",
|
||||
"ui.second_rail": "Second Rail",
|
||||
"ui.section_analysis": "Section Analysis",
|
||||
|
||||
@@ -1372,6 +1372,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Selecciona los cuerpos que se van a guardar",
|
||||
"ui.save_mesh_temp_failed": "No se ha podido crear {folder}: {error}",
|
||||
"ui.saved_to": "Guardado en {path}",
|
||||
"ui.sbox_pin": "Fijar arriba en la búsqueda S",
|
||||
"ui.sbox_unpin": "Desfijar",
|
||||
"ui.scale": "Escala",
|
||||
"ui.second_rail": "Segunda línea guía",
|
||||
"ui.section_analysis": "Análisis de sección",
|
||||
|
||||
@@ -628,8 +628,10 @@
|
||||
"cmd.timeline_roll_to": "Déplacer le marqueur d'historique",
|
||||
"cmd.timeline_rollback": "Déplacer le marqueur de chronologie",
|
||||
"cmd.timeline_suppress": "Désactiver la fonction",
|
||||
"cmd.view_ambient_occlusion": "Occlusion ambiante",
|
||||
"cmd.view_apply": "Restaurer la vue nommée",
|
||||
"cmd.view_delete": "Supprimer la vue nommée",
|
||||
"cmd.view_graphics_preset": "Préréglage graphique",
|
||||
"cmd.view_list": "Lister les vues nommées",
|
||||
"cmd.view_navigate": "Naviguer dans la vue",
|
||||
"cmd.view_rename": "Renommer la vue nommée",
|
||||
@@ -995,6 +997,8 @@
|
||||
"ui.ai_toolbar_button_note": "Le bouton IA à l'extrémité droite de la barre d'outils. Ctrl+Shift+A et le menu Vue ouvrent toujours le panneau.",
|
||||
"ui.ai_undo_tip": "Annuler tout ce que l'agent a modifié pour ce message, en une seule étape",
|
||||
"ui.all": "Tout",
|
||||
"ui.ambient_occlusion": "Occlusion ambiante",
|
||||
"ui.ambient_occlusion_help": "Ombrage doux aux jonctions des surfaces opaques. Nécessite la vue GPU.",
|
||||
"ui.analysis": "Analyse",
|
||||
"ui.angle": "Angle",
|
||||
"ui.angle_decimals": "Décimales des angles",
|
||||
@@ -1155,6 +1159,11 @@
|
||||
"ui.fully_constrained": "Entièrement contrainte",
|
||||
"ui.general": "Général",
|
||||
"ui.graphics": "Graphisme",
|
||||
"ui.graphics_custom": "Personnalisé",
|
||||
"ui.graphics_performance": "Performances",
|
||||
"ui.graphics_preset": "Préréglage graphique",
|
||||
"ui.graphics_preset_help": "Qualité : occlusion ambiante, tessellation fine et suréchantillonnage adaptatif 2×. Performances : sans occlusion ambiante, tessellation normale et sans suréchantillonnage. Les réglages manuels affichent Personnalisé. MSAA reste inchangé.",
|
||||
"ui.graphics_quality": "Qualité",
|
||||
"ui.grid_follows_view": "La grille suit la vue",
|
||||
"ui.grid_follows_view_hint": "Dans les vues de côté, la grille s'affiche sur le plan vertical qui leur fait face plutôt que sur le sol",
|
||||
"ui.ground_shadow": "Ombre au sol",
|
||||
@@ -1379,6 +1388,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Sélectionner les corps à enregistrer",
|
||||
"ui.save_mesh_temp_failed": "Impossible de créer {folder} : {error}",
|
||||
"ui.saved_to": "Enregistré : {path}",
|
||||
"ui.sbox_pin": "Épingler en haut de la boîte S",
|
||||
"ui.sbox_unpin": "Désépingler",
|
||||
"ui.scale": "Échelle",
|
||||
"ui.second_rail": "Second rail",
|
||||
"ui.section_analysis": "Analyse de coupe",
|
||||
|
||||
@@ -1337,6 +1337,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Selecciona os corpos que queres gardar",
|
||||
"ui.save_mesh_temp_failed": "Non se puido crear {folder}: {error}",
|
||||
"ui.saved_to": "Gardado {path}",
|
||||
"ui.sbox_pin": "Fixar arriba na caixa S",
|
||||
"ui.sbox_unpin": "Desfixar",
|
||||
"ui.scale": "Escala",
|
||||
"ui.section_analysis": "Análise de sección",
|
||||
"ui.select": "Seleccionar",
|
||||
|
||||
@@ -345,6 +345,7 @@
|
||||
"cmd.component_move_sketches": "Sposta schizzi nel componente",
|
||||
"cmd.component_paste_new": "Incolla come nuovo",
|
||||
"cmd.component_properties": "Proprietà fisiche del componente",
|
||||
"cmd.component_relink": "Ricollega sorgente",
|
||||
"cmd.component_rename": "Rinomina componente",
|
||||
"cmd.component_revert_position": "Ripristina posizione",
|
||||
"cmd.component_unlock": "Sblocca contenuto",
|
||||
@@ -1342,6 +1343,11 @@
|
||||
"ui.rectangle": "Rettangolo",
|
||||
"ui.rectangular_pattern": "Ripetizione rettangolare",
|
||||
"ui.redo": "Ripeti",
|
||||
"ui.relink_design_changed": "L'assieme è cambiato durante la scelta del sorgente. Avvia di nuovo Ricollega sorgente.",
|
||||
"ui.relink_done": "Sorgente ricollegato per tutte le istanze.",
|
||||
"ui.relink_not_linked": "Questo componente non è collegato a un progetto sorgente.",
|
||||
"ui.relink_select_one": "Seleziona un componente collegato o una delle sue istanze.",
|
||||
"ui.relink_source_all": "Ricollega sorgente (tutte le istanze)…",
|
||||
"ui.removeFromToolbar": "Rimuovi dalla barra degli strumenti",
|
||||
"ui.removeSection": "Rimuovi sezione",
|
||||
"ui.render": "Rendering",
|
||||
@@ -1363,6 +1369,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Seleziona i corpi da salvare",
|
||||
"ui.save_mesh_temp_failed": "Impossibile creare {folder}: {error}",
|
||||
"ui.saved_to": "Salvato {path}",
|
||||
"ui.sbox_pin": "Fissa in cima al riquadro S",
|
||||
"ui.sbox_unpin": "Sblocca",
|
||||
"ui.scale": "Scala",
|
||||
"ui.second_rail": "Seconda guida",
|
||||
"ui.section_analysis": "Analisi in sezione",
|
||||
|
||||
+22
-2
@@ -350,6 +350,7 @@
|
||||
"cmd.component_move_sketches": "スケッチをコンポーネントに移動",
|
||||
"cmd.component_paste_new": "新規として貼り付け",
|
||||
"cmd.component_properties": "コンポーネントの物理的プロパティ",
|
||||
"cmd.component_relink": "参照元を再リンク",
|
||||
"cmd.component_rename": "コンポーネントの名前を変更",
|
||||
"cmd.component_revert_position": "位置を元に戻す",
|
||||
"cmd.component_unlock": "内容のロックを解除",
|
||||
@@ -632,8 +633,10 @@
|
||||
"cmd.timeline_roll_to": "履歴マーカーを移動",
|
||||
"cmd.timeline_rollback": "タイムライン マーカーを移動",
|
||||
"cmd.timeline_suppress": "フィーチャーを抑制",
|
||||
"cmd.view_ambient_occlusion": "アンビエントオクルージョン",
|
||||
"cmd.view_apply": "名前付きビューを復元",
|
||||
"cmd.view_delete": "名前付きビューを削除",
|
||||
"cmd.view_graphics_preset": "グラフィックスプリセット",
|
||||
"cmd.view_list": "名前付きビューを一覧表示",
|
||||
"cmd.view_navigate": "ビューナビゲーション",
|
||||
"cmd.view_rename": "名前付きビューの名前を変更",
|
||||
@@ -857,7 +860,7 @@
|
||||
"ui.agent_chat_approval": "エージェントにこの操作を許可しますか?",
|
||||
"ui.agent_chat_attach_view": "ビューを添付",
|
||||
"ui.agent_chat_attach_view_tip": "現在のビューの画像をメッセージと一緒に送ります",
|
||||
"ui.agent_chat_attachable": "画像・テキスト・CAD ファイル",
|
||||
"ui.agent_chat_attachable": "画像・PDF・テキスト・CAD ファイル",
|
||||
"ui.agent_chat_auth_agent": "{name} でサインイン",
|
||||
"ui.agent_chat_auth_env": "環境変数 {vars} を設定して SolidCraft を再起動してください。",
|
||||
"ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。SolidCraft がパスワードやキーを見ることはありません。",
|
||||
@@ -889,7 +892,6 @@
|
||||
"ui.agent_chat_file_error": "{name}: {error}",
|
||||
"ui.agent_chat_file_heic": "{name}: HEIC 画像はここでは読めません。先に JPEG か PNG に書き出してください",
|
||||
"ui.agent_chat_file_importing": "デザインにインポート中…",
|
||||
"ui.agent_chat_file_kinds": "{name}: 添付できるのは画像 (PNG、JPEG、GIF、WebP、BMP、TIFF)、テキストファイル (SVG も)、CAD ファイル (STEP、IGES、FreeCAD、Rhino、STL、3MF、OBJ、glTF) です",
|
||||
"ui.agent_chat_file_loading": "ファイルを読み込み中…",
|
||||
"ui.agent_chat_file_not_a_file": "{name}: ファイルではありません",
|
||||
"ui.agent_chat_file_not_a_picture": "{name}: 画像ではありません",
|
||||
@@ -1036,6 +1038,8 @@
|
||||
"ui.ai_toolbar_button_note": "ツールバー右端の AI ボタン。Ctrl+Shift+A と表示メニューからも引き続きパネルを開けます。",
|
||||
"ui.ai_undo_tip": "このメッセージでエージェントが行った変更を、1回でまとめて元に戻します",
|
||||
"ui.all": "すべて",
|
||||
"ui.ambient_occlusion": "アンビエントオクルージョン",
|
||||
"ui.ambient_occlusion_help": "不透明な面が接する部分に柔らかい陰影を加えます。GPUビューポートが必要です。",
|
||||
"ui.analysis": "解析",
|
||||
"ui.angle": "角度",
|
||||
"ui.angle_decimals": "角度の小数点以下の桁数",
|
||||
@@ -1196,6 +1200,11 @@
|
||||
"ui.fully_constrained": "完全拘束",
|
||||
"ui.general": "一般",
|
||||
"ui.graphics": "グラフィックス",
|
||||
"ui.graphics_custom": "カスタム",
|
||||
"ui.graphics_performance": "パフォーマンス",
|
||||
"ui.graphics_preset": "グラフィックスプリセット",
|
||||
"ui.graphics_preset_help": "品質:アンビエントオクルージョン、細かい表示品質、適応型2×スーパーサンプリング。パフォーマンス:AOなし、標準の表示品質、スーパーサンプリングなし。手動変更はカスタムと表示されます。MSAAは変更されません。",
|
||||
"ui.graphics_quality": "品質",
|
||||
"ui.grid_follows_view": "グリッドをビューに追従させる",
|
||||
"ui.grid_follows_view_hint": "側面ビューでは、グリッドを地面ではなく、そのビューに向いた垂直平面に表示します",
|
||||
"ui.ground_shadow": "地面の影",
|
||||
@@ -1337,6 +1346,10 @@
|
||||
"ui.newSketchGroup": "新しいスケッチグループ",
|
||||
"ui.new_body": "新規ボディ",
|
||||
"ui.new_design": "新規デザイン",
|
||||
"ui.new_extrude_distance": "新規押し出しの距離",
|
||||
"ui.new_extrude_distance_fixed": "固定 (10 mm)",
|
||||
"ui.new_extrude_distance_last_used": "前回の値",
|
||||
"ui.new_extrude_distance_tip": "新しい押し出しの開始距離です。既存の押し出しを編集するときは、その押し出しの値が使われます。",
|
||||
"ui.no_slicer_found": "スライサーが見つかりません",
|
||||
"ui.normal": "標準",
|
||||
"ui.normalConstruction": "通常 / コンストラクション",
|
||||
@@ -1400,6 +1413,11 @@
|
||||
"ui.rectangle": "長方形",
|
||||
"ui.rectangular_pattern": "矩形パターン",
|
||||
"ui.redo": "やり直す",
|
||||
"ui.relink_design_changed": "参照元の選択中にアセンブリが変更されました。再リンクをやり直してください。",
|
||||
"ui.relink_done": "すべてのインスタンスの参照元を再リンクしました。",
|
||||
"ui.relink_not_linked": "このコンポーネントは参照元の設計にリンクされていません。",
|
||||
"ui.relink_select_one": "リンクされたコンポーネントまたはそのインスタンスを1つ選択してください。",
|
||||
"ui.relink_source_all": "参照元を再リンク(すべてのインスタンス)…",
|
||||
"ui.removeFromToolbar": "ツールバーから削除",
|
||||
"ui.removeSection": "セクションを解除",
|
||||
"ui.render": "レンダリング",
|
||||
@@ -1423,6 +1441,8 @@
|
||||
"ui.save_mesh_pick_bodies": "保存するボディを選択してください",
|
||||
"ui.save_mesh_temp_failed": "{folder} を作成できません: {error}",
|
||||
"ui.saved_to": "保存しました: {path}",
|
||||
"ui.sbox_pin": "Sボックスの上部に固定",
|
||||
"ui.sbox_unpin": "固定を解除",
|
||||
"ui.scale": "尺度",
|
||||
"ui.second_rail": "第2レール",
|
||||
"ui.section_analysis": "断面解析",
|
||||
|
||||
@@ -425,8 +425,10 @@
|
||||
"cmd.timeline_roll_to": "이력 마커 이동",
|
||||
"cmd.timeline_rollback": "타임라인 마커 이동",
|
||||
"cmd.timeline_suppress": "특성 비활성화",
|
||||
"cmd.view_ambient_occlusion": "앰비언트 오클루전",
|
||||
"cmd.view_apply": "이름 있는 보기 복원",
|
||||
"cmd.view_delete": "이름 있는 보기 삭제",
|
||||
"cmd.view_graphics_preset": "그래픽 프리셋",
|
||||
"cmd.view_list": "이름 있는 보기 목록",
|
||||
"cmd.view_navigate": "보기 탐색",
|
||||
"cmd.view_rename": "이름 있는 보기 이름 변경",
|
||||
@@ -756,6 +758,8 @@
|
||||
"ui.ai_toolbar_button_note": "도구 모음 오른쪽 끝의 AI 버튼. Ctrl+Shift+A와 보기 메뉴로도 패널을 열 수 있습니다.",
|
||||
"ui.ai_undo_tip": "이 메시지에서 에이전트가 바꾼 모든 것을 한 번에 실행 취소",
|
||||
"ui.all": "모두",
|
||||
"ui.ambient_occlusion": "앰비언트 오클루전",
|
||||
"ui.ambient_occlusion_help": "불투명한 표면이 만나는 곳에 부드러운 음영을 추가합니다. GPU 뷰포트가 필요합니다.",
|
||||
"ui.analysis": "분석",
|
||||
"ui.angle": "각도",
|
||||
"ui.angle_decimals": "각도 소수점 자릿수",
|
||||
@@ -910,6 +914,11 @@
|
||||
"ui.fully_constrained": "완전 구속",
|
||||
"ui.general": "일반",
|
||||
"ui.graphics": "그래픽",
|
||||
"ui.graphics_custom": "사용자 지정",
|
||||
"ui.graphics_performance": "성능",
|
||||
"ui.graphics_preset": "그래픽 프리셋",
|
||||
"ui.graphics_preset_help": "품질: 앰비언트 오클루전, 세밀한 표시 품질, 적응형 2× 슈퍼샘플링. 성능: 앰비언트 오클루전 끄기, 보통 표시 품질, 슈퍼샘플링 끄기. 수동 변경 시 사용자 지정으로 표시됩니다. MSAA는 유지됩니다.",
|
||||
"ui.graphics_quality": "품질",
|
||||
"ui.groupSelected": "선택 항목 그룹화",
|
||||
"ui.heading_assemble": "조립",
|
||||
"ui.heading_box": "박스",
|
||||
@@ -1116,6 +1125,8 @@
|
||||
"ui.save_mesh_pick_bodies": "저장할 바디를 선택하세요",
|
||||
"ui.save_mesh_temp_failed": "{folder}을(를) 만들 수 없습니다: {error}",
|
||||
"ui.saved_to": "저장됨: {path}",
|
||||
"ui.sbox_pin": "S 상자 맨 위에 고정",
|
||||
"ui.sbox_unpin": "고정 해제",
|
||||
"ui.scale": "축척",
|
||||
"ui.section_analysis": "단면 분석",
|
||||
"ui.select": "선택",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1384,6 +1384,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Zaznacz bryły do zapisania",
|
||||
"ui.save_mesh_temp_failed": "Nie udało się utworzyć {folder}: {error}",
|
||||
"ui.saved_to": "Zapisano {path}",
|
||||
"ui.sbox_pin": "Przypnij na górze okna S",
|
||||
"ui.sbox_unpin": "Odepnij",
|
||||
"ui.scale": "Skala",
|
||||
"ui.second_rail": "Druga prowadnica",
|
||||
"ui.section_analysis": "Analiza przekroju",
|
||||
|
||||
@@ -1317,6 +1317,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Selecione os corpos a salvar",
|
||||
"ui.save_mesh_temp_failed": "Não foi possível criar {folder}: {error}",
|
||||
"ui.saved_to": "Salvo {path}",
|
||||
"ui.sbox_pin": "Fixar no topo da caixa S",
|
||||
"ui.sbox_unpin": "Desafixar",
|
||||
"ui.scale": "Escala",
|
||||
"ui.section_analysis": "Análise de seção",
|
||||
"ui.select": "Selecionar",
|
||||
|
||||
@@ -568,8 +568,10 @@
|
||||
"cmd.timeline_roll_to": "Переместить маркер истории",
|
||||
"cmd.timeline_rollback": "Сдвинуть маркер шкалы",
|
||||
"cmd.timeline_suppress": "Подавить операцию",
|
||||
"cmd.view_ambient_occlusion": "Окружающее затенение",
|
||||
"cmd.view_apply": "Вернуть именованный вид",
|
||||
"cmd.view_delete": "Удалить именованный вид",
|
||||
"cmd.view_graphics_preset": "Графический профиль",
|
||||
"cmd.view_list": "Список именованных видов",
|
||||
"cmd.view_navigate": "Навигация вида",
|
||||
"cmd.view_rename": "Переименовать именованный вид",
|
||||
@@ -750,6 +752,8 @@
|
||||
"ui.agent_cursor": "Курсор агента",
|
||||
"ui.agent_cursor_speed": "Скорость курсора агента",
|
||||
"ui.all": "Все",
|
||||
"ui.ambient_occlusion": "Окружающее затенение",
|
||||
"ui.ambient_occlusion_help": "Мягкое затенение в местах соприкосновения непрозрачных поверхностей. Требуется видовой экран GPU.",
|
||||
"ui.analysis": "Анализ",
|
||||
"ui.angle": "Угол",
|
||||
"ui.angle_decimals": "Знаков после запятой (углы)",
|
||||
@@ -899,6 +903,11 @@
|
||||
"ui.fully_constrained": "Полностью определён",
|
||||
"ui.general": "Общие",
|
||||
"ui.graphics": "Графика",
|
||||
"ui.graphics_custom": "Настраиваемый",
|
||||
"ui.graphics_performance": "Производительность",
|
||||
"ui.graphics_preset": "Графический профиль",
|
||||
"ui.graphics_preset_help": "Качество: окружающее затенение, детальная тесселяция и адаптивный суперсэмплинг 2×. Производительность: без затенения, обычная тесселяция и без суперсэмплинга. Ручные изменения отображаются как настраиваемые. MSAA не меняется.",
|
||||
"ui.graphics_quality": "Качество",
|
||||
"ui.ground_shadow": "Тень на земле",
|
||||
"ui.groupSelected": "Группировать выбранное",
|
||||
"ui.heading_add_ins": "НАДСТРОЙКИ",
|
||||
@@ -1116,6 +1125,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Выберите тела для сохранения",
|
||||
"ui.save_mesh_temp_failed": "Не удалось создать {folder}: {error}",
|
||||
"ui.saved_to": "Сохранено: {path}",
|
||||
"ui.sbox_pin": "Закрепить вверху панели S",
|
||||
"ui.sbox_unpin": "Открепить",
|
||||
"ui.scale": "Масштаб",
|
||||
"ui.section_analysis": "Анализ сечения",
|
||||
"ui.select": "Выбрать",
|
||||
|
||||
@@ -1264,6 +1264,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Kaydedilecek gövdeleri seçin",
|
||||
"ui.save_mesh_temp_failed": "{folder} oluşturulamadı: {error}",
|
||||
"ui.saved_to": "{path} konumuna kaydedildi",
|
||||
"ui.sbox_pin": "S kutusunun en üstüne sabitle",
|
||||
"ui.sbox_unpin": "Sabitlemeyi kaldır",
|
||||
"ui.scale": "Ölçekle",
|
||||
"ui.section_analysis": "Kesit analizi",
|
||||
"ui.select": "Seç",
|
||||
|
||||
@@ -1340,6 +1340,8 @@
|
||||
"ui.save_mesh_pick_bodies": "Виберіть тіла для збереження",
|
||||
"ui.save_mesh_temp_failed": "Не вдалося створити {folder}: {error}",
|
||||
"ui.saved_to": "Збережено {path}",
|
||||
"ui.sbox_pin": "Закріпити вгорі поля S",
|
||||
"ui.sbox_unpin": "Відкріпити",
|
||||
"ui.scale": "Масштаб",
|
||||
"ui.section_analysis": "Аналіз перерізу",
|
||||
"ui.select": "Вибрати",
|
||||
|
||||
+22
-2
@@ -350,6 +350,7 @@
|
||||
"cmd.component_move_sketches": "将草图移至组件",
|
||||
"cmd.component_paste_new": "粘贴为新对象",
|
||||
"cmd.component_properties": "组件物理属性",
|
||||
"cmd.component_relink": "重新链接源文件",
|
||||
"cmd.component_rename": "重命名组件",
|
||||
"cmd.component_revert_position": "恢复位置",
|
||||
"cmd.component_unlock": "解锁内容",
|
||||
@@ -632,8 +633,10 @@
|
||||
"cmd.timeline_roll_to": "滚动历史标记",
|
||||
"cmd.timeline_rollback": "移动时间线标记",
|
||||
"cmd.timeline_suppress": "抑制特征",
|
||||
"cmd.view_ambient_occlusion": "环境光遮蔽",
|
||||
"cmd.view_apply": "还原命名视图",
|
||||
"cmd.view_delete": "删除命名视图",
|
||||
"cmd.view_graphics_preset": "图形预设",
|
||||
"cmd.view_list": "列出命名视图",
|
||||
"cmd.view_navigate": "导航视图",
|
||||
"cmd.view_rename": "重命名命名视图",
|
||||
@@ -857,7 +860,7 @@
|
||||
"ui.agent_chat_approval": "允许智能体执行此操作吗?",
|
||||
"ui.agent_chat_attach_view": "附加视图",
|
||||
"ui.agent_chat_attach_view_tip": "随消息发送当前视图的图片",
|
||||
"ui.agent_chat_attachable": "图片、文本和 CAD 文件",
|
||||
"ui.agent_chat_attachable": "图片、PDF、文本和 CAD 文件",
|
||||
"ui.agent_chat_auth_agent": "使用 {name} 登录",
|
||||
"ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 SolidCraft。",
|
||||
"ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;SolidCraft 从不接触你的密码或密钥。",
|
||||
@@ -889,7 +892,6 @@
|
||||
"ui.agent_chat_file_error": "{name}:{error}",
|
||||
"ui.agent_chat_file_heic": "{name}:这里无法读取 HEIC 图片;请先导出为 JPEG 或 PNG",
|
||||
"ui.agent_chat_file_importing": "正在导入设计…",
|
||||
"ui.agent_chat_file_kinds": "{name}:可以附加图片(PNG、JPEG、GIF、WebP、BMP、TIFF)、文本文件(包括 SVG)和 CAD 文件(STEP、IGES、FreeCAD、Rhino、STL、3MF、OBJ、glTF)",
|
||||
"ui.agent_chat_file_loading": "正在读取文件…",
|
||||
"ui.agent_chat_file_not_a_file": "{name}:不是文件",
|
||||
"ui.agent_chat_file_not_a_picture": "{name}:不是图片",
|
||||
@@ -1036,6 +1038,8 @@
|
||||
"ui.ai_toolbar_button_note": "工具栏右端的 AI 按钮。Ctrl+Shift+A 和视图菜单仍可打开面板。",
|
||||
"ui.ai_undo_tip": "将智能体为此消息所做的全部更改一步撤销",
|
||||
"ui.all": "全部",
|
||||
"ui.ambient_occlusion": "环境光遮蔽",
|
||||
"ui.ambient_occlusion_help": "在不透明表面相接处添加柔和阴影。需要 GPU 视口。",
|
||||
"ui.analysis": "分析",
|
||||
"ui.angle": "角度",
|
||||
"ui.angle_decimals": "角度小数位",
|
||||
@@ -1196,6 +1200,11 @@
|
||||
"ui.fully_constrained": "完全约束",
|
||||
"ui.general": "常规",
|
||||
"ui.graphics": "图形",
|
||||
"ui.graphics_custom": "自定义",
|
||||
"ui.graphics_performance": "性能",
|
||||
"ui.graphics_preset": "图形预设",
|
||||
"ui.graphics_preset_help": "质量:环境光遮蔽、精细显示质量和自适应2×超采样。性能:关闭环境光遮蔽、普通显示质量和关闭超采样。手动修改显示为自定义。MSAA保持不变。",
|
||||
"ui.graphics_quality": "质量",
|
||||
"ui.grid_follows_view": "网格跟随视图",
|
||||
"ui.grid_follows_view_hint": "在侧视图中,将网格显示在朝向该视图的竖直平面上,而不是地面上",
|
||||
"ui.ground_shadow": "地面阴影",
|
||||
@@ -1337,6 +1346,10 @@
|
||||
"ui.newSketchGroup": "新建草图组",
|
||||
"ui.new_body": "新建实体",
|
||||
"ui.new_design": "新建设计",
|
||||
"ui.new_extrude_distance": "新建拉伸距离",
|
||||
"ui.new_extrude_distance_fixed": "固定 (10 mm)",
|
||||
"ui.new_extrude_distance_last_used": "上次使用",
|
||||
"ui.new_extrude_distance_tip": "新建拉伸的起始距离。编辑已有拉伸时保留其自身的值。",
|
||||
"ui.no_slicer_found": "未找到切片软件",
|
||||
"ui.normal": "法向",
|
||||
"ui.normalConstruction": "普通 / 构造",
|
||||
@@ -1400,6 +1413,11 @@
|
||||
"ui.rectangle": "矩形",
|
||||
"ui.rectangular_pattern": "矩形阵列",
|
||||
"ui.redo": "重做",
|
||||
"ui.relink_design_changed": "选择源文件时装配已更改。请重新开始链接。",
|
||||
"ui.relink_done": "已为所有实例重新链接源文件。",
|
||||
"ui.relink_not_linked": "此组件未链接到源设计。",
|
||||
"ui.relink_select_one": "请先选择一个链接组件或其实例。",
|
||||
"ui.relink_source_all": "重新链接源文件(所有实例)…",
|
||||
"ui.removeFromToolbar": "从工具栏移除",
|
||||
"ui.removeSection": "移除剖面",
|
||||
"ui.render": "渲染",
|
||||
@@ -1423,6 +1441,8 @@
|
||||
"ui.save_mesh_pick_bodies": "请选择要保存的实体",
|
||||
"ui.save_mesh_temp_failed": "无法创建 {folder}:{error}",
|
||||
"ui.saved_to": "已保存:{path}",
|
||||
"ui.sbox_pin": "固定到 S 框顶部",
|
||||
"ui.sbox_unpin": "取消固定",
|
||||
"ui.scale": "缩放",
|
||||
"ui.second_rail": "第二导轨",
|
||||
"ui.section_analysis": "截面分析",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT OR Apache-2.0</project_license>
|
||||
<name>SolidCraft</name>
|
||||
<summary>Parametric 3D CAD in the style of Fusion</summary>
|
||||
<summary>Independent parametric 3D CAD</summary>
|
||||
<developer id="io.github.bherbruck">
|
||||
<name>SolidCraft contributors</name>
|
||||
</developer>
|
||||
|
||||
@@ -10,6 +10,7 @@ struct U {
|
||||
acc_u: vec4<f32>, // accessibility map axes (xyz, offset)
|
||||
acc_v: vec4<f32>,
|
||||
eye: vec4<f32>, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
|
||||
ao: vec4<f32>, // radius in world units (zero: disabled)
|
||||
// analysis colours (`theme::AnalysisColors`): draft positive, between, negative; reachable,
|
||||
// blocked; curvature flat, middle, tight
|
||||
ana_c: array<vec4<f32>, 8>,
|
||||
@@ -28,6 +29,7 @@ fn near_z(c: vec4<f32>) -> f32 {
|
||||
}
|
||||
|
||||
@group(1) @binding(2) var acc_t: texture_2d<f32>;
|
||||
@group(2) @binding(0) var ao_depth: texture_depth_2d;
|
||||
|
||||
fn clipped(p: vec3<f32>) -> bool {
|
||||
return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w;
|
||||
@@ -119,7 +121,7 @@ fn solid(i: TOut, front: bool) -> vec4<f32> {
|
||||
let fw = fwidth(ndv);
|
||||
if (clipped(i.wp)) { discard; }
|
||||
if (!front && length(u.clip.xyz) > 0.5) {
|
||||
// The cut face in its body's colour (as Fusion's "from component"), tinted toward the
|
||||
// The cut face in its body's colour ("from component" colouring), tinted toward the
|
||||
// cap colour so it reads as a cut, hatched at 45 degrees on screen.
|
||||
let base = mix(i.c.rgb, u.cap.rgb, 0.35);
|
||||
let h = fract((i.pos.x + i.pos.y) / 9.0);
|
||||
@@ -128,7 +130,7 @@ fn solid(i: TOut, front: bool) -> vec4<f32> {
|
||||
if (u.ana.x > 0.5 && length(i.n) > 0.5) {
|
||||
return analysis(i, k);
|
||||
}
|
||||
let c = shade(i);
|
||||
let c = lit(i, occlusion(i));
|
||||
// Silhouettes (styles with edges): where a curved surface turns away from the eye, a line
|
||||
// about 1.5 px wide, on the visible surface only (depth-correct).
|
||||
if (u.screen.w > 0.5 && length(i.n) > 0.5 && fw > 1e-6) {
|
||||
@@ -138,6 +140,59 @@ 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);
|
||||
@@ -230,6 +285,10 @@ fn fs_tri_all(i: TOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
|
||||
fn shade(i: TOut) -> vec4<f32> {
|
||||
return lit(i, 1.0);
|
||||
}
|
||||
|
||||
fn lit(i: TOut, visibility: f32) -> vec4<f32> {
|
||||
// A zero normal marks flat colour (selection fills, translucent planes).
|
||||
if (length(i.n) < 0.5) {
|
||||
return out_color(i.c);
|
||||
@@ -242,7 +301,7 @@ fn shade(i: TOut) -> vec4<f32> {
|
||||
let fill = max(dot(n, v), 0.0);
|
||||
let h = normalize(key + v);
|
||||
let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25;
|
||||
let k = 0.42 + 0.38 * diff + 0.25 * fill;
|
||||
let k = 0.42 + 0.38 * diff + 0.25 * fill - (0.42 + 0.25 * fill) * (1.0 - visibility);
|
||||
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), i.c.a));
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ pub(crate) fn remap_sheet_metadata(st: &mut ModelState, names: &[String], transf
|
||||
.filter(|(_, t)| names.contains(t.body.as_str()))
|
||||
.map(|(i, t)| {
|
||||
let point = apply_point(transform, t.axis_point);
|
||||
let axis = apply_vector(transform, t.axis).normalized().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?;
|
||||
let axis = apply_vector(transform, *t.axis).unit().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?;
|
||||
if !point.is_finite() {
|
||||
return Err(DocError::Invalid("invalid transferred thread placement".into()));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Canvases: reference images placed on a plane (Fusion's Insert Canvas), kept in the design.
|
||||
//! Canvases: reference images placed on a plane (Insert Canvas), kept in the design.
|
||||
//! The image bytes travel inside the design file (base64), so it reopens without the original.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -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 (Fusion's CONSTRUCT panel).
|
||||
//! built from them (the CONSTRUCT panel).
|
||||
//!
|
||||
//! Face and edge references carry the persistent name of what was picked (see `naming`), so
|
||||
//! they follow the model when earlier features change; the picked point is the fallback.
|
||||
@@ -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(), point: pl.origin }, pl.origin))
|
||||
Ok((FaceSurface::Plane { normal: pl.normal().unit().ok_or_else(|| bad("degenerate plane"))?, point: pl.origin }, pl.origin))
|
||||
}
|
||||
_ => Err(bad("pick a face")),
|
||||
}
|
||||
|
||||
@@ -812,11 +812,11 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
line: Option<String>,
|
||||
},
|
||||
/// A construction axis (Fusion's CONSTRUCT axes).
|
||||
/// A construction axis (CONSTRUCT axes).
|
||||
ConstructionAxis {
|
||||
def: AxisDef,
|
||||
},
|
||||
/// A construction point (Fusion's CONSTRUCT points).
|
||||
/// A construction point (CONSTRUCT points).
|
||||
ConstructionPoint {
|
||||
def: PointDef,
|
||||
},
|
||||
@@ -2065,7 +2065,7 @@ impl FeatureKind {
|
||||
)
|
||||
}
|
||||
|
||||
/// Fusion-like type name (timeline tooltip, `inspect`).
|
||||
/// Short type name (timeline tooltip, `inspect`).
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
|
||||
@@ -3273,7 +3273,7 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Density of the default material (g/cm³), as Fusion's default.
|
||||
/// Density of the default material (g/cm³), as the default.
|
||||
#[path = "../../../assets/materials/library.rs"]
|
||||
mod materials;
|
||||
pub use materials::{DEFAULT_DENSITY, MATERIALS, material_color};
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
use serde::Serialize;
|
||||
use solidcraft_geom::tess::TessPlan;
|
||||
use solidcraft_geom::{Mesh, Plane, Region2, Vec2, Vec3, regions_apart};
|
||||
use solidcraft_geom::{Mesh, Plane, Region2, UnitVec3, Vec2, Vec3, regions_apart};
|
||||
use solidcraft_kernel::{self as kernel, Body, BoolOp};
|
||||
use solidcraft_sketch::{CurveKind, Profile, SolveReport, find_profiles};
|
||||
|
||||
@@ -179,7 +179,7 @@ pub struct ThreadInfo {
|
||||
pub pitch: f64,
|
||||
pub internal: bool,
|
||||
pub axis_point: Vec3,
|
||||
pub axis: Vec3,
|
||||
pub axis: UnitVec3,
|
||||
/// Threaded part along the axis from `axis_point`.
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
@@ -365,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
|
||||
@@ -2390,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)
|
||||
@@ -5805,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);
|
||||
t2.axis = crate::apply_vector(&frame, *t.axis).unit().unwrap_or(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 == ';' {
|
||||
// Fusion separates function arguments with `;` in some locales.
|
||||
// Some CAD tools separate function arguments with `;` in some locales.
|
||||
out.push((Tok::Op(','), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if "+-*/^(),%".contains(c) {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! - 5: joins, cuts and intersects change only their own component's bodies unless bodies are
|
||||
//! named as `participants`; older features in designs with components get `participants:
|
||||
//! ["*"]` (every body), which is what they did, so those designs keep their shape.
|
||||
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
|
||||
//! - 4: joint origins mate by default (B's z against A's); `flip` now means
|
||||
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
|
||||
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
|
||||
//! explicit planes retaining their previous axes, including offset-plane expressions.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A joint keeps the expressions its offset, angle and values were typed as (`exprs`) and works
|
||||
//! them out again whenever the design changes, so it follows the parameters they name. Older
|
||||
//! designs need no transformation: a joint without `exprs` keeps its numbers as before. The last
|
||||
//! release refuses a design saved with this (the newer format number).
|
||||
+160
-2
@@ -260,6 +260,92 @@ pub struct Joint {
|
||||
pub angle: f64,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub suppressed: bool,
|
||||
/// The expressions the offset, the angle and the values were typed as: they are worked out
|
||||
/// again whenever the design changes, so a joint follows the parameters it is defined by.
|
||||
#[serde(default, skip_serializing_if = "JointExprs::is_empty")]
|
||||
pub exprs: JointExprs,
|
||||
}
|
||||
|
||||
/// Expressions a joint's numbers follow (over the design's parameters): the offset (a length),
|
||||
/// the angle and each value (angles or lengths as the joint's values are). A number typed in
|
||||
/// place of one drops it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct JointExprs {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub angle: Option<String>,
|
||||
/// By value index, as in `Joint::values`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub values: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
impl JointExprs {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.offset.is_none() && self.angle.is_none() && self.values.iter().all(Option::is_none)
|
||||
}
|
||||
|
||||
/// The expression value `i` follows.
|
||||
pub fn value(&self, i: usize) -> Option<&str> {
|
||||
self.values.get(i).and_then(Option::as_deref)
|
||||
}
|
||||
|
||||
/// Value `i` follows `e` (or nothing).
|
||||
pub fn set_value(&mut self, i: usize, e: Option<String>) {
|
||||
if e.is_some() && self.values.len() <= i {
|
||||
self.values.resize(i + 1, None);
|
||||
}
|
||||
if let Some(slot) = self.values.get_mut(i) {
|
||||
*slot = e;
|
||||
}
|
||||
while self.values.last().is_some_and(Option::is_none) {
|
||||
self.values.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest offset or value an expression may give a joint (mm, or radians for an angle).
|
||||
const MAX_EXPR_VALUE: f64 = 1e9;
|
||||
|
||||
/// A joint's offset, angle and values worked out from its expressions (those without one keep
|
||||
/// their numbers), or why one can't be: an unknown name, a wrong unit, or a number too large.
|
||||
pub fn expressed(doc: &Document, j: &Joint) -> Result<(f64, f64, Vec<f64>)> {
|
||||
let eval = |e: &str, kind: crate::expr::Kind, what: &str| -> Result<f64> {
|
||||
let v = doc.eval(e, kind).map_err(|err| DocError::Invalid(format!("joint {}: {what} `{e}`: {err}", j.name)))?;
|
||||
if v.is_finite() && v.abs() < MAX_EXPR_VALUE { Ok(v) } else { Err(DocError::Invalid(format!("joint {}: {what} `{e}` gives {v}", j.name))) }
|
||||
};
|
||||
let x = &j.exprs;
|
||||
let offset = match &x.offset {
|
||||
Some(e) => eval(e, crate::expr::Kind::Length, "offset")?,
|
||||
None => j.offset,
|
||||
};
|
||||
let angle = match &x.angle {
|
||||
Some(e) => eval(e, crate::expr::Kind::Angle, "angle")?,
|
||||
None => j.angle,
|
||||
};
|
||||
let mut values = j.values.clone();
|
||||
for (i, e) in x.values.iter().enumerate().take(j.kind.dofs().len()) {
|
||||
let Some(e) = e else { continue };
|
||||
let kind = if j.kind.is_angle(i) { crate::expr::Kind::Angle } else { crate::expr::Kind::Length };
|
||||
let name = j.kind.dofs().get(i).copied().unwrap_or("value");
|
||||
let v = eval(e, kind, name)?;
|
||||
if values.len() <= i {
|
||||
values.resize(i + 1, 0.0);
|
||||
}
|
||||
if let Some(slot) = values.get_mut(i) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
Ok((offset, angle, values))
|
||||
}
|
||||
|
||||
/// Values the loop solver leaves alone: those a motion link sets and those an expression does.
|
||||
fn set_values(asm: &Assembly) -> BTreeSet<(u64, usize)> {
|
||||
let mut set: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
|
||||
for j in &asm.joints {
|
||||
set.extend(j.exprs.values.iter().enumerate().filter(|(_, e)| e.is_some()).map(|(i, _)| (j.id, i)));
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset, or for a
|
||||
@@ -446,6 +532,49 @@ pub struct Assembly {
|
||||
}
|
||||
|
||||
impl Assembly {
|
||||
/// Every expression the joints and motion links keep, with the joint (or the driven joint of
|
||||
/// a link) and what it sets: `offset`, `angle`, a value's name, or a coupling's length.
|
||||
pub fn exprs(&self) -> Vec<(u64, String, &str)> {
|
||||
let mut out = Vec::new();
|
||||
for j in &self.joints {
|
||||
let x = &j.exprs;
|
||||
out.extend(x.offset.as_deref().map(|e| (j.id, "offset".to_string(), e)));
|
||||
out.extend(x.angle.as_deref().map(|e| (j.id, "angle".to_string(), e)));
|
||||
for (i, e) in x.values.iter().enumerate() {
|
||||
if let Some(e) = e {
|
||||
out.push((j.id, j.kind.dofs().get(i).copied().unwrap_or("value").to_string(), e.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in &self.links {
|
||||
for s in l.coupling.as_ref().map(Coupling::sizes).unwrap_or_default() {
|
||||
if let Size::Expr(e) = s {
|
||||
out.push((l.b, "coupling".to_string(), e.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The expressions of `exprs`, to rewrite (a renamed parameter).
|
||||
pub fn exprs_mut(&mut self) -> Vec<&mut String> {
|
||||
let mut out = Vec::new();
|
||||
for j in &mut self.joints {
|
||||
let x = &mut j.exprs;
|
||||
out.extend(x.offset.as_mut());
|
||||
out.extend(x.angle.as_mut());
|
||||
out.extend(x.values.iter_mut().flatten());
|
||||
}
|
||||
for l in &mut self.links {
|
||||
for s in l.coupling.as_mut().map(Coupling::sizes_mut).unwrap_or_default() {
|
||||
if let Size::Expr(e) = s {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.joints.is_empty()
|
||||
&& self.origins.is_empty()
|
||||
@@ -858,7 +987,7 @@ fn close_loops(tree: &Tree, closing: &[u64], asm: &Assembly, held: &Held, vals:
|
||||
if closing.len() > MAX_LOOPS {
|
||||
return (Values::new(), stuck);
|
||||
}
|
||||
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
|
||||
let linked = set_values(asm);
|
||||
let groups = loop_groups(tree, closing, asm, &linked);
|
||||
let spent = std::cell::Cell::new(0usize);
|
||||
for (group, around, touches) in &groups {
|
||||
@@ -1053,7 +1182,7 @@ fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &m
|
||||
if closing.len() > MAX_LOOPS {
|
||||
return;
|
||||
}
|
||||
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
|
||||
let linked = set_values(asm);
|
||||
let groups = loop_groups(tree, closing, asm, &linked);
|
||||
// The joints loops depend on: those around them, those whose values they read, and those a
|
||||
// motion link carries any of these values into.
|
||||
@@ -1466,6 +1595,14 @@ 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));
|
||||
@@ -1600,6 +1737,26 @@ 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)
|
||||
@@ -1631,6 +1788,7 @@ mod tests {
|
||||
offset: 0.0,
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
})
|
||||
.collect();
|
||||
let links =
|
||||
|
||||
@@ -967,6 +967,7 @@ mod tests {
|
||||
offset: 0.0,
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
};
|
||||
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
|
||||
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.
|
||||
|
||||
@@ -10,6 +10,7 @@ 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.
|
||||
@@ -71,7 +72,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), ..c }
|
||||
CylinderFace { axis_point: apply_point(m, c.axis_point), axis: apply_vector(m, *c.axis).unit().unwrap_or(c.axis), ..c }
|
||||
}
|
||||
|
||||
/// The face across the rim at the `at` end (along the axis) of cylinder `c`, face `face` of `b`
|
||||
@@ -170,21 +171,32 @@ 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| {
|
||||
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));
|
||||
let chosen = end.and_then(|name| end_named(nf.body, nf.face, &own, name)).unwrap_or_else(|| end_by_pick(&nf, &own));
|
||||
Ok(Some(Round::at(placed(&nf.offset, own), chosen)))
|
||||
}
|
||||
|
||||
/// 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,6 +486,9 @@ 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 {
|
||||
@@ -498,6 +501,7 @@ impl ParamUser {
|
||||
Some(row) => format!("configuration `{row}` ({column})"),
|
||||
None => format!("configuration column {column}"),
|
||||
},
|
||||
ParamUser::Joint { name, input, .. } => format!("joint {name} ({input})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -615,6 +619,12 @@ 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
|
||||
}
|
||||
|
||||
@@ -719,6 +729,13 @@ 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);
|
||||
}
|
||||
|
||||
+61
-27
@@ -3,13 +3,16 @@
|
||||
//!
|
||||
//! 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. 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.
|
||||
//! 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.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solidcraft_geom::{Mesh, Plane, Vec2, Vec3};
|
||||
use solidcraft_geom::{Mesh, Plane, Seg2, Vec2, Vec3};
|
||||
use solidcraft_sketch::{CurveKind, LinkGeom, LinkKind, LinkSource, Sketch};
|
||||
|
||||
use crate::eval::ModelState;
|
||||
@@ -117,7 +120,8 @@ 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);
|
||||
edge_geom(plane, kind, mesh.edges.get(e).cloned().unwrap_or_default(), &mut wires)
|
||||
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)
|
||||
}
|
||||
LinkSource::Face { body, at } => {
|
||||
let (bi, mesh) = body_mesh(st, body, *at)?;
|
||||
@@ -133,13 +137,8 @@ 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"))?;
|
||||
for e in projection_face_edges(target, &mesh, f, plane)? {
|
||||
if let Some(pts) = mesh.edges.get(e) {
|
||||
g.extend(fit_world(plane, pts));
|
||||
}
|
||||
}
|
||||
let g = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, f, plane)?);
|
||||
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
|
||||
}
|
||||
}
|
||||
@@ -282,19 +281,62 @@ fn off_plane(plane: &Plane, pts: &[Vec3]) -> bool {
|
||||
pts.iter().any(|p| plane.height(*p).abs() > size * 1e-7)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// 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> {
|
||||
match kind {
|
||||
LinkKind::Intersect => section_of_polyline(plane, &pts),
|
||||
LinkKind::Include if off_plane(plane, &pts) => {
|
||||
wires.push(pts);
|
||||
Vec::new()
|
||||
}
|
||||
_ => fit_world(plane, &pts),
|
||||
_ => exact.unwrap_or_else(|| 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> {
|
||||
@@ -331,7 +373,8 @@ 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 geom = edge_geom(plane, kind, pts, &mut wires);
|
||||
let exact = exact_edges(target, plane, &[index]).pop().flatten();
|
||||
let geom = edge_geom(plane, kind, exact, pts, &mut wires);
|
||||
if geom.is_empty() && wires.is_empty() {
|
||||
return Err(DocError::Invalid("named edge projects to nothing on this sketch plane".into()));
|
||||
}
|
||||
@@ -350,12 +393,7 @@ 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 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 = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, index, plane)?);
|
||||
let geom = merge_split_circles(geom);
|
||||
if certified_planar_construction(target, index as usize) { merge_family_lines(geom)? } else { geom }
|
||||
};
|
||||
@@ -425,11 +463,7 @@ pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body
|
||||
} else {
|
||||
order::family_edges(&mesh, &selected, plane)?
|
||||
};
|
||||
for edge in edges {
|
||||
if let Some(points) = mesh.edges.get(edge) {
|
||||
geometry.extend(fit_world(plane, points));
|
||||
}
|
||||
}
|
||||
geometry.extend(projected_edges(body, &mesh, plane, &edges));
|
||||
}
|
||||
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 {
|
||||
/// Fusion's default: Steel (mm), 2.5 mm, K 0.44, bend radius = Thickness.
|
||||
/// 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 as Fusion does: a round hole is one face bounded by two closed circular
|
||||
/// Topology counted by faces and loops: 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, Vec2, Vec3};
|
||||
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, UnitVec3, 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: Vec3,
|
||||
n: UnitVec3,
|
||||
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: Vec3) -> f64 {
|
||||
fn area_about(pts: &[Vec3], n: UnitVec3) -> 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,7 +298,14 @@ fn measure_pair(
|
||||
));
|
||||
return out;
|
||||
}
|
||||
Ok(m) => out.overlap = Some((m.volume, m.area, m.bbox, m.centroid)),
|
||||
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));
|
||||
}
|
||||
Err(e) => {
|
||||
out.unchecked = Some(e.to_string());
|
||||
return out;
|
||||
@@ -375,6 +382,42 @@ 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,3 +251,74 @@ 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,6 +11,8 @@ 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"]
|
||||
@@ -52,7 +54,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]; 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]; 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"),
|
||||
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"),
|
||||
@@ -416,21 +418,28 @@ 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 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"))?,
|
||||
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)
|
||||
};
|
||||
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,6 +22,7 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
|
||||
offset: 2.0,
|
||||
angle: 0.1,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ 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; \
|
||||
@@ -23,6 +28,9 @@ 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"),
|
||||
@@ -216,6 +224,66 @@ 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)?;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! 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)))
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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]
|
||||
|
||||
@@ -2188,6 +2188,10 @@ 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)"),
|
||||
.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)"),
|
||||
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
@@ -218,7 +218,20 @@ 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)?;
|
||||
add_step(s, path, str_(p, "name"), &options)
|
||||
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)
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -369,6 +369,7 @@ 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 (as Fusion does): count its
|
||||
// Sheet metal keeps the edges where bends meet flat faces (so bends stay visible): count its
|
||||
// B-rep faces as they are, without merging coplanar neighbours.
|
||||
let sheets = s.model.state().sheets.clone();
|
||||
for b in &mut bodies {
|
||||
|
||||
+100
-11
@@ -8,7 +8,7 @@ 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, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap, XRef};
|
||||
use solidcraft_doc::joints::{self, Coupling, Joint, JointExprs, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap, XRef};
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
@@ -23,7 +23,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"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); \
|
||||
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?; angle?; name?; quiet?: bool (leave out dof and fixed)",
|
||||
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",
|
||||
),
|
||||
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; \
|
||||
@@ -31,7 +34,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; 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; a value driven drops the expression joint.create gave it; 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: \
|
||||
@@ -65,10 +68,12 @@ 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?, suppressed?, name?"),
|
||||
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.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), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \
|
||||
"→ joints (type, occurrences, values, limits; expressions: {offset?, angle?, values?} when they were typed as expressions), 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"),
|
||||
@@ -80,10 +85,39 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
/// 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.
|
||||
@@ -449,6 +483,28 @@ 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")) {
|
||||
@@ -574,10 +630,10 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => 0.0,
|
||||
};
|
||||
let angle = match p.get("angle") {
|
||||
Some(v) => value(s, v, true, cmd)?,
|
||||
None => 0.0,
|
||||
Some(v) => Some(value(s, v, true, cmd)?),
|
||||
None => None,
|
||||
};
|
||||
let j = Joint {
|
||||
let mut j = Joint {
|
||||
id,
|
||||
name,
|
||||
kind,
|
||||
@@ -587,9 +643,15 @@ 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: angle.unwrap_or(0.0),
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -622,6 +684,7 @@ 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)
|
||||
}
|
||||
@@ -666,6 +729,11 @@ 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());
|
||||
@@ -1126,15 +1194,26 @@ 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;
|
||||
@@ -1192,8 +1271,12 @@ 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]]}));
|
||||
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)})
|
||||
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
|
||||
})
|
||||
.collect();
|
||||
let links: Vec<Value> = s
|
||||
@@ -1327,6 +1410,9 @@ pub(crate) fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, u
|
||||
#[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)]
|
||||
@@ -1338,3 +1424,6 @@ mod reach_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_tests.rs"]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_turn_tests.rs"]
|
||||
mod turn_tests;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! 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,3 +265,31 @@ 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}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! 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, Vec3};
|
||||
use solidcraft_geom::{Mesh, UnitVec3, 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, Vec3, f64)> {
|
||||
fn fit_circle(pts: &[Vec3], tol: f64) -> Option<(Vec3, UnitVec3, 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, Vec3, f64)> {
|
||||
return None;
|
||||
}
|
||||
let center = a + (v * u.len2() - u * v.len2()).cross(w) * (1.0 / (2.0 * w.len2()));
|
||||
let axis = w.normalized()?;
|
||||
let axis = w.unit()?;
|
||||
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: Vec3, start: Vec3, angle: f64) -> Vec<Vec3> {
|
||||
fn arc_points(center: Vec3, axis: UnitVec3, 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();
|
||||
let normal = ss.plane.normal().unit()?;
|
||||
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();
|
||||
let normal = ss.plane.normal().unit()?;
|
||||
// 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<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
|
||||
let normals: Vec<UnitVec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).unit()).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, json, shape, slot })
|
||||
Some(Item { geo: Geo::Tris(tris), dir: planar.map(Vec3::from), 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::Vec3;
|
||||
use solidcraft_geom::{UnitVec3, 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: Vec3,
|
||||
axis: UnitVec3,
|
||||
radius: f64,
|
||||
angle: f64,
|
||||
planar: bool,
|
||||
@@ -26,7 +26,7 @@ pub(crate) enum Shape {
|
||||
},
|
||||
Plane {
|
||||
point: Vec3,
|
||||
normal: Vec3,
|
||||
normal: UnitVec3,
|
||||
},
|
||||
Line {
|
||||
a: Vec3,
|
||||
@@ -70,8 +70,7 @@ 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: Vec3, radius: f64, pts: &[Vec3]) -> Option<Shape> {
|
||||
let axis = axis.normalized()?;
|
||||
pub(crate) fn cylinder(origin: Vec3, axis: UnitVec3, radius: f64, pts: &[Vec3]) -> Option<Shape> {
|
||||
if pts.is_empty() || !(radius.is_finite() && radius > 0.0) {
|
||||
return None;
|
||||
}
|
||||
@@ -83,7 +82,7 @@ pub(crate) fn cylinder(origin: Vec3, axis: Vec3, radius: f64, pts: &[Vec3]) -> O
|
||||
}
|
||||
|
||||
/// The angle around the axis the points cover: a whole turn less the widest gap between them.
|
||||
pub(crate) fn coverage(center: Vec3, axis: Vec3, pts: &[Vec3]) -> f64 {
|
||||
pub(crate) fn coverage(center: Vec3, axis: UnitVec3, pts: &[Vec3]) -> f64 {
|
||||
let x = axis.any_perp();
|
||||
let y = axis.cross(x);
|
||||
let mut angles: Vec<f64> = pts
|
||||
@@ -105,7 +104,7 @@ pub(crate) fn coverage(center: Vec3, axis: Vec3, 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: Vec3, pts: &[Vec3]) -> Option<Vec3> {
|
||||
fn bulge(center: Vec3, axis: UnitVec3, 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)
|
||||
@@ -114,7 +113,7 @@ fn bulge(center: Vec3, axis: Vec3, pts: &[Vec3]) -> Option<Vec3> {
|
||||
}
|
||||
|
||||
/// Closest points of two infinite lines (a point and a unit direction each).
|
||||
fn line_line(p1: Vec3, d1: Vec3, p2: Vec3, d2: Vec3) -> (Vec3, Vec3) {
|
||||
fn line_line(p1: Vec3, d1: UnitVec3, p2: Vec3, d2: UnitVec3) -> (Vec3, Vec3) {
|
||||
let w = p1 - p2;
|
||||
let b = d1.dot(d2);
|
||||
let denom = 1.0 - b * b;
|
||||
@@ -127,8 +126,8 @@ fn line_line(p1: Vec3, d1: Vec3, p2: Vec3, d2: Vec3) -> (Vec3, Vec3) {
|
||||
(p1 + d1 * s, p2 + d2 * t)
|
||||
}
|
||||
|
||||
fn parallel(a: Vec3, b: Vec3) -> bool {
|
||||
a.cross(b).len() < 1e-6
|
||||
fn parallel(a: impl Into<Vec3>, b: impl Into<Vec3>) -> bool {
|
||||
a.into().cross(b).len() < 1e-6
|
||||
}
|
||||
|
||||
/// A measurement from a round item's centre or axis.
|
||||
@@ -214,7 +213,7 @@ fn from_round(a: &Shape, b: &Shape) -> Option<Centre> {
|
||||
}
|
||||
}
|
||||
Shape::Line { a: la, b: lb } => {
|
||||
let dir = (lb - la).normalized()?;
|
||||
let dir = (lb - la).unit()?;
|
||||
let across = dir.dot(na).abs() < 1e-6;
|
||||
if pa {
|
||||
// A circle/arc has a fixed centre. Project that point onto the picked
|
||||
@@ -342,7 +341,7 @@ mod tests {
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
fn circle(c: Vec3, r: f64) -> Shape {
|
||||
Shape::Round { center: c, axis: Vec3::Z, radius: r, angle: TAU, planar: true, lo: 0.0, hi: 0.0 }
|
||||
Shape::Round { center: c, axis: UnitVec3::Z, radius: r, angle: TAU, planar: true, lo: 0.0, hi: 0.0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -367,8 +366,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn arcs_on_different_planes_measure_between_their_centres() {
|
||||
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 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 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());
|
||||
@@ -377,10 +376,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 [Vec3::Z, Vec3::X] {
|
||||
for axis in [UnitVec3::Z, UnitVec3::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 == Vec3::Z { Vec3::new(34.0, 19.0, 10.0) } else { Vec3::new(0.0, 19.0, 27.0) };
|
||||
let expected = if axis == UnitVec3::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();
|
||||
@@ -390,8 +389,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: 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 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 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);
|
||||
@@ -399,8 +398,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: 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 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 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);
|
||||
@@ -408,12 +407,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: Vec3::Z }).is_none());
|
||||
assert!(centre(&cyl, &Shape::Plane { point: Vec3::ZERO, normal: UnitVec3::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: Vec3::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: UnitVec3::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);
|
||||
@@ -425,7 +424,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: Vec3::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: UnitVec3::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);
|
||||
@@ -442,7 +441,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 [Vec3::X, Vec3::Y, Vec3::Z] {
|
||||
for axis in [UnitVec3::X, UnitVec3::Y, UnitVec3::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);
|
||||
@@ -468,8 +467,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, Vec3::Z, &ring(32, PI)) - PI).abs() < 1e-9);
|
||||
assert_eq!(coverage(Vec3::ZERO, Vec3::Z, &ring(32, TAU)), TAU);
|
||||
assert!((coverage(Vec3::ZERO, UnitVec3::Z, &ring(32, PI)) - PI).abs() < 1e-9);
|
||||
assert_eq!(coverage(Vec3::ZERO, UnitVec3::Z, &ring(32, TAU)), TAU);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -580,3 +580,85 @@ 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}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#![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}");
|
||||
}
|
||||
}
|
||||
@@ -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 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",
|
||||
"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",
|
||||
),
|
||||
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,6 +275,8 @@ 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).
|
||||
@@ -1435,7 +1437,7 @@ fn keep_size(sk: &Sketch, e: Ent) -> Vec<ConstraintKind> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Satisfy a just-added constraint the way Fusion applies it: the first-picked entities stay
|
||||
/// Satisfy a just-added constraint the way a dimensional-constraint tool 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. Like Fusion, the corner now stays as a virtual sharp corner (a point
|
||||
/// a fillet radius too long. 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,29 +104,31 @@ impl Path {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line or spline
|
||||
/// Drag: the grabbed entity follows the cursor where possible (a point moves toward 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. When none past it solves, the first failed step stays placed but unsolved:
|
||||
/// 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:
|
||||
/// 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 share one frame's decomposition
|
||||
/// budget ([`Budget::frame`]); otherwise each has a solve's own.
|
||||
/// `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.
|
||||
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 = path.points().filter_map(|q| Some((q, sk.point(q)?))).collect();
|
||||
let moving: Vec<_> = 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
|
||||
@@ -211,6 +213,41 @@ 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 {
|
||||
@@ -225,8 +262,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, the decomposition work they did, and whether one ran
|
||||
/// out of budget.
|
||||
/// The held solves of one drag: how many, their shared work (including cursor correction),
|
||||
/// and whether it ran out of budget.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(super) struct Solves {
|
||||
pub count: usize,
|
||||
|
||||
@@ -257,6 +257,31 @@ 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) };
|
||||
@@ -305,6 +330,7 @@ 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.
|
||||
@@ -638,7 +664,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, as in Fusion. Otherwise None: the
|
||||
/// from the far end to it, so the part keeps its size. 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,3 +1585,37 @@ 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 (Fusion's default).
|
||||
/// Project the edges of the face a new sketch was created on (the 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
|
||||
|
||||
@@ -609,3 +609,71 @@ fn a_rectangle_put_onto_a_projected_edge_stays_a_closed_profile() {
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((volume(&reopened, "Pad") - 3000.0).abs() < 1e-6, "{}", volume(&reopened, "Pad"));
|
||||
}
|
||||
|
||||
/// A face with letters cut into it, extruded as a cut: its edges come into the face's sketch
|
||||
/// as the letters' exact curves (a font's quadratics and cubics), not fitted to the mesh, so the
|
||||
/// tool's walls lie on the letters' walls and the cut takes what the same region picked in the
|
||||
/// sketch takes (#1360). Fitted to the mesh (684 lines, 31 arcs and 18 splines here), they lay
|
||||
/// a few hundredths off the letters' walls, and the boolean found no result.
|
||||
#[test]
|
||||
fn a_face_engraved_with_text_projects_its_letters_exactly_and_extrudes() {
|
||||
let volume = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
let engraved = || {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":40}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"face":[50,13,40]}}));
|
||||
run(&mut s, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":6.5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":-1,"operation":"cut"}));
|
||||
s
|
||||
};
|
||||
// The face round the letters, drawn in a sketch in its plane.
|
||||
let mut drawn = engraved();
|
||||
let before = volume(&mut drawn);
|
||||
run(&mut drawn, "sketch.create", json!({"plane":"XY","offset":40}));
|
||||
run(&mut drawn, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]}));
|
||||
run(&mut drawn, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":6.5}));
|
||||
run(&mut drawn, "sketch.finish", json!({}));
|
||||
run(&mut drawn, "solid.extrude", json!({"distance":-3.5,"operation":"cut","profiles":[{"point":[1,1]}]}));
|
||||
let taken = before - volume(&mut drawn);
|
||||
assert!(taken > 0.0, "{taken}");
|
||||
// The engraved face itself, extruded as a cut.
|
||||
let mut s = engraved();
|
||||
let id = run(&mut s, "solid.extrude", json!({"distance":-3.5,"operation":"cut","face":[1,1,40]}))["feature"].as_u64().unwrap();
|
||||
let r = s.model.result(id).unwrap();
|
||||
assert!(r.error.is_none(), "{r:?}");
|
||||
let face = before - volume(&mut s);
|
||||
assert!((face - taken).abs() < 1e-6 * before, "{face} {taken}");
|
||||
// Its sketch got the letters as Bézier pieces.
|
||||
let st = s.model.state();
|
||||
let sketch = &st.sketches.last().unwrap().sketch;
|
||||
let pieces =
|
||||
sketch.curves.iter().filter(|c| c.link.is_some() && matches!(c.kind, solidcraft_sketch::CurveKind::Spline { control: true, .. })).count();
|
||||
assert!(pieces > 100, "{pieces}");
|
||||
}
|
||||
|
||||
/// A design saved before free-form edges came in exactly, by the build before it: an "S" cut
|
||||
/// 1 mm into a 30 × 20 × 10 block, then a sketch on that face (its edges projected, the letter's
|
||||
/// fitted to the mesh as 72 lines, 9 arcs and 2 splines) with a circle cut 3 mm. On reopening, the
|
||||
/// face's link is laid out again with the letter's exact pieces, and the part builds the same.
|
||||
#[test]
|
||||
fn a_face_sketch_saved_with_fitted_letters_reopens_and_builds_the_same() {
|
||||
let text = include_str!("../../../../tests/projection/before-1508/engraved_face_sketched.json");
|
||||
let mut s = Session::new(solidcraft_doc::Document::from_json(text).unwrap());
|
||||
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
for name in ["Extrude2", "Sketch3", "Extrude3"] {
|
||||
let id = s.doc.find_feature(name).unwrap().id;
|
||||
let r = s.model.result(id).unwrap();
|
||||
assert!(r.error.is_none(), "{name}: {:?}", r.error);
|
||||
}
|
||||
// The build that saved it measured 5916.018831 mm³.
|
||||
assert!((volume - 5916.018830904924).abs() < 1e-3, "{volume}");
|
||||
let st = s.model.state();
|
||||
let sketch = &st.sketches.last().unwrap().sketch;
|
||||
let pieces =
|
||||
sketch.curves.iter().filter(|c| c.link.is_some() && matches!(c.kind, solidcraft_sketch::CurveKind::Spline { control: true, .. })).count();
|
||||
assert!(pieces > 0, "{pieces}");
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ fn trim_cases() {
|
||||
let _ = r;
|
||||
let mut s = new_sketch();
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
|
||||
// No crossings: the whole line goes (Fusion deletes it).
|
||||
// No crossings: the whole line goes (it is deleted).
|
||||
let r = s.execute("sketch.trim", &json!({"curve": l, "at": [5, 0]}));
|
||||
sound_or_empty(&s);
|
||||
assert!(r.is_ok() || r.is_err());
|
||||
|
||||
@@ -1524,3 +1524,22 @@ fn the_first_dimension_does_not_scale_a_sketch_that_has_a_size() {
|
||||
let out = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l2"], "value": "60 deg", "scale_sketch": true}));
|
||||
assert!(out.get("scaled").is_none(), "{out}");
|
||||
}
|
||||
|
||||
/// The cursor fallback shares the held solves' frame budget; a scripted drag can finish later.
|
||||
#[test]
|
||||
fn cursor_fallback_respects_an_exhausted_live_frame_budget() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [-10, 0]], "ids": ["crank"], "infer": false}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["crank"], "value": 10}));
|
||||
let target = v(-10.0, 16.0);
|
||||
let nearest = target * (10.0 / target.len());
|
||||
super::drag::last_drag::with_frame_work(0, || {
|
||||
run(&mut s, "sketch.drag", json!({"entity": "crank.end", "from": [-10, 0], "to": [target.x, target.y], "live": true}));
|
||||
});
|
||||
let at = pt(&s, "crank.end");
|
||||
assert!(at.dist(nearest) > 1.0, "an exhausted frame must not start unbudgeted cursor work: {at:?}");
|
||||
assert!((at.len() - 10.0).abs() < 1e-6);
|
||||
assert!(s.model.state().sketch(s.active_sketch.unwrap()).unwrap().report.ok());
|
||||
run(&mut s, "sketch.drag", json!({"entity": "crank.end", "from": [at.x, at.y], "to": [target.x, target.y], "live": false}));
|
||||
assert!(pt(&s, "crank.end").dist(nearest) < 1e-4, "scripted motion retains the full cursor objective");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ use super::CommandSpec;
|
||||
use crate::params::{bad, num, req_vec2, req_vec3, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("view.navigate", "Navigate View", navigate)
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("view.graphics_preset", "Graphics Preset", graphics_preset)
|
||||
.icon("settings").noundo().doc_only().params("preset: performance|quality → preset, enabled, display_quality, supersampling, supersampling_adaptive; Quality: AO, Fine, adaptive 2x SSAA; Performance: no AO, Normal, no SSAA"),
|
||||
CommandSpec::new("view.ambient_occlusion", "Ambient Occlusion", ambient_occlusion)
|
||||
.icon("settings").noundo().doc_only().params("enabled: bool → enabled; apply soft contact shading in the GPU viewport"),
|
||||
CommandSpec::new("view.navigate", "Navigate View", navigate)
|
||||
.icon("orbit")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
@@ -91,16 +96,8 @@ fn continuous(mut cam: Camera, p: &Value) -> Result<Value> {
|
||||
cam.pan(delta.x, delta.y, viewport()?.y);
|
||||
} else if p.get("pivot").is_some() {
|
||||
let pivot = req_vec3(cmd, p, "pivot")?;
|
||||
let size = viewport()?;
|
||||
let before = cam.to_screen(pivot, size.x, size.y, 1.0);
|
||||
cam.orbit(delta.x, delta.y);
|
||||
let after = cam.to_screen(pivot, size.x, size.y, 1.0);
|
||||
if let (Some((ax, ay, _)), Some((bx, by, _))) = (before, after) {
|
||||
// Pan operates at target depth. Perspective screen correction must instead
|
||||
// use the pivot's depth after orbiting; lateral pan leaves that depth fixed.
|
||||
let scale = if cam.fov > 0.0 { (cam.eye() - pivot).dot(cam.back()) / cam.distance } else { 1.0 };
|
||||
cam.pan((ax - bx) * scale, (ay - by) * scale, size.y);
|
||||
}
|
||||
viewport()?;
|
||||
cam.orbit_about(delta.x, delta.y, pivot);
|
||||
} else {
|
||||
cam.orbit(delta.x, delta.y);
|
||||
}
|
||||
@@ -294,3 +291,61 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ambient_occlusion(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let enabled = p.get("enabled").and_then(Value::as_bool).ok_or_else(|| bad("view.ambient_occlusion", "enabled must be a boolean"))?;
|
||||
Ok(json!({"enabled": enabled}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ambient_occlusion_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn validates_display_setting_without_editing_design_or_history() {
|
||||
let mut s = Session::default();
|
||||
let before = s.doc.to_json();
|
||||
for enabled in [true, false] {
|
||||
assert_eq!(s.execute("view.ambient_occlusion", &json!({"enabled": enabled})).unwrap(), json!({"enabled": enabled}));
|
||||
}
|
||||
for p in [json!({}), json!({"enabled": 1}), json!({"enabled": "true"}), json!({"enabled": null})] {
|
||||
assert!(s.execute("view.ambient_occlusion", &p).is_err());
|
||||
}
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert!(s.undo.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// One preset for contact shading, tessellation and supersampling. MSAA stays independent.
|
||||
fn graphics_preset(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let preset = p.get("preset").and_then(Value::as_str);
|
||||
let enabled = match preset {
|
||||
Some("quality") => true,
|
||||
Some("performance") => false,
|
||||
_ => return Err(bad("view.graphics_preset", "preset must be performance or quality")),
|
||||
};
|
||||
Ok(json!({"preset": preset, "enabled": enabled, "display_quality": if enabled { "fine" } else { "normal" },
|
||||
"supersampling": if enabled { 2.0 } else { 1.0 }, "supersampling_adaptive": true}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod graphics_preset_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn presets_validate_without_changing_design_or_history() {
|
||||
let mut s = Session::default();
|
||||
let before = s.doc.to_json();
|
||||
for (preset, enabled) in [("quality", true), ("performance", false)] {
|
||||
assert_eq!(
|
||||
s.execute("view.graphics_preset", &json!({"preset":preset})).unwrap(),
|
||||
json!({"preset":preset,"enabled":enabled,"display_quality":if enabled { "fine" } else { "normal" },
|
||||
"supersampling":if enabled { 2.0 } else { 1.0 },"supersampling_adaptive":true})
|
||||
);
|
||||
}
|
||||
for params in [json!({}), json!({"preset":"fast"}), json!({"preset":true}), json!({"preset":null})] {
|
||||
assert!(s.execute("view.graphics_preset", ¶ms).is_err());
|
||||
}
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert!(s.undo.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ fn a_large_sheet_shows_its_small_bends_and_holes_round() {
|
||||
let (mut bends, mut walls) = (0, 0);
|
||||
for face in 0..b.body.face_count() {
|
||||
let Some(FaceSurface::Cylinder { origin, axis, radius }) = face_surface(&b.body, face) else { continue };
|
||||
let (steps, widest) = steps_around(&m, face, origin, axis);
|
||||
let (steps, widest) = steps_around(&m, face, origin, *axis);
|
||||
assert!(widest <= DISPLAY_ANGLE * 1.001, "radius {radius}: a facet spans {} degrees", widest.to_degrees());
|
||||
if (radius - 2.0).abs() < 1e-6 {
|
||||
// Half of a hole's wall.
|
||||
|
||||
@@ -322,3 +322,55 @@ fn sketch_floating_in_the_gap_touching_neither_still_drills_down() {
|
||||
near(base - volume(&mut s), pi() * 25.0 * 4.0);
|
||||
assert!(!solid_at(&s, 20.0, 20.0, 4.0) && solid_at(&s, 20.0, 20.0, 16.0));
|
||||
}
|
||||
|
||||
/// #2387: a drill point crossing the sloping back of a wall must leave a real hole.
|
||||
#[test]
|
||||
fn drilled_sketch_points_break_through_a_sloping_back_face() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Wall"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [15, 0], [15, 30], [7.5, 30], [0, 0]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Wall", "distance": 40, "direction": "negative"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [15, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "Pts"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[5, 16], [10, 16]], "ids": ["l10"]}));
|
||||
run(&mut s, "sketch.point", json!({"point": [30, 16], "id": "p12"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.hole",
|
||||
json!({"sketch": "Pts", "points": ["l10.end", "p12"], "depth": "10 mm", "diameter": "5 mm", "type": "drilled", "tip_angle": "118 deg", "name": "H"}),
|
||||
);
|
||||
let id = s.doc.find_feature("H").unwrap().id;
|
||||
let result = s.model.result(id).unwrap();
|
||||
assert!(result.error.is_none());
|
||||
assert!(result.boolean_diagnostics.unverified().is_none());
|
||||
// The cylindrical bore and cone, less the oblique conical cap outside x = z/4.
|
||||
let r = 2.5;
|
||||
let k = 59f64.to_radians().tan();
|
||||
let tip = r / k;
|
||||
let outside = 4.0 - (5.0 - tip);
|
||||
let cap = pi() * k * k * outside.powi(3) / (3.0 * (1.0 - (k / 4.0).powi(2)).powf(1.5));
|
||||
let expected = 2.0 * (pi() * r * r * (10.0 + tip / 3.0) - cap);
|
||||
let left = volume(&mut s);
|
||||
// The measured curved faces are tessellated; 0.05 mm³ is 0.013% of the removed material.
|
||||
assert!((13_500.0 - left - expected).abs() < 0.05, "removed {} vs {expected}", 13_500.0 - left);
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
for b in &s.model.state().bodies {
|
||||
assert!(b.body.validity().is_empty());
|
||||
assert_eq!(b.body.shell_count(), 1);
|
||||
}
|
||||
for y in [10.0, 30.0] {
|
||||
assert!(!solid_at(&s, 10.0, y, 16.0), "bore at y = {y}");
|
||||
assert!(!solid_at(&s, 4.2, y, 16.0), "tip breaks through at y = {y}");
|
||||
assert!(solid_at(&s, 10.0, y + 3.0, 16.0), "material beside the bore at y = {y}");
|
||||
}
|
||||
let saved = s.doc.to_json();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
near(volume(&mut s), 13_500.0);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
near(volume(&mut s), left);
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
|
||||
assert!(reopened.model.result(id).unwrap().error.is_none());
|
||||
near(volume(&mut reopened), left);
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ pub struct Session {
|
||||
pub log: Vec<String>,
|
||||
/// Increments on every document change (views use it to refresh).
|
||||
pub revision: u64,
|
||||
/// Project a face's edges into a sketch created on it (Fusion's default; `sketch.auto_project`).
|
||||
/// Project a face's edges into a sketch created on it (the default; `sketch.auto_project`).
|
||||
pub auto_project: bool,
|
||||
/// The component new sketches and features go into (0 = the root).
|
||||
pub active_component: u64,
|
||||
|
||||
@@ -88,7 +88,7 @@ fn split_pieces_count_and_move_apart() {
|
||||
// Measure counts the two halves of the top as two faces.
|
||||
let m = run(&mut s, "inspect.measure", json!({ "bodies": ["B"] }))["bodies"][0].clone();
|
||||
assert_eq!(m["faces"].as_u64(), Some(7), "{m}");
|
||||
// Offset Face on one half moves that half only (Fusion): a 20 × 30 step 5 high.
|
||||
// Offset Face on one half moves that half only: a 20 × 30 step 5 high.
|
||||
run(&mut s, "solid.offset_face", json!({"faces": [[10, 15, 20]], "distance": 5}));
|
||||
let (_, v) = faces_volume(&mut s, "B");
|
||||
assert!((v - 27000.0).abs() < 1e-6 * 27000.0, "{v}");
|
||||
|
||||
@@ -652,7 +652,7 @@ fn sketch_tools_and_constraints() {
|
||||
assert!(s.execute("sketch.line", &json!({"points": [[0, 0], [1, 1]]})).is_err(), "needs an active sketch");
|
||||
}
|
||||
|
||||
/// The recipe format of the Fusion oracle (01-box) drives the same commands.
|
||||
/// The recipe format of the 01-box reference drives the same commands.
|
||||
#[test]
|
||||
fn script_runs_commands() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -232,7 +232,7 @@ fn shell_of_a_filleted_box() {
|
||||
|
||||
/// The first tutorial part: a 60×40×15 plate with its top front edge and front right edge
|
||||
/// rounded r2, shelled with the top open. At 2 mm the rounds' inner sides collapse to sharp
|
||||
/// edges (as in Fusion); at 1.5 mm the top's round, tangent to the open top, keeps its
|
||||
/// edges (the intended behaviour); at 1.5 mm the top's round, tangent to the open top, keeps its
|
||||
/// tangency (every face moves in, the opening is swept out through the top).
|
||||
#[test]
|
||||
fn shell_of_the_tutorial_plate() {
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
//!
|
||||
//! Each seed is its own test (`seed_00` … `seed_95`), so the runner spreads them over the CI
|
||||
//! shards and their cores: a few seeds take minutes, and grouped four ways they made one test
|
||||
//! of half an hour. Pull requests that touch `crates/kernel` run every 8th seed (ci.yml);
|
||||
//! pushes to main and the nightly run every seed.
|
||||
//! of half an hour. Pull requests that can change them run every seed but the slowest few
|
||||
//! (`SLOW_SEEDS` in ci.yml); the merge queue, pushes to main and the nightly run every seed.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -202,3 +202,29 @@ fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
|
||||
run(&mut again, "solid.move", json!({"bodies": ["Post-1"], "translate": [0, 0, 5]}));
|
||||
assert_eq!(body_names(&again), ["Plate-1", "Post-1"]);
|
||||
}
|
||||
|
||||
/// `matrix` places the inserted file's component, after `up`, in the same command (one undo
|
||||
/// step); a matrix that is not rigid is refused before anything is added.
|
||||
#[test]
|
||||
fn a_step_file_can_be_inserted_at_a_matrix() {
|
||||
let path = frame_file("matrix");
|
||||
let quarter = json!([0, -1, 0, 100, 1, 0, 0, 0, 0, 0, 1, 0]);
|
||||
let mut s = Session::default();
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": path, "matrix": quarter}));
|
||||
// (x, y, z) goes to (100 - y, x, z): the frame (0..40, 0..34, 0..20) spans x 66..100, y 0..40.
|
||||
let (lo, hi) = extent(&s);
|
||||
assert!(near(lo, [66.0, 0.0, 0.0]) && near(hi, [100.0, 40.0, 20.0]), "{lo:?} {hi:?} {r}");
|
||||
assert_eq!(r["transform"][3][0], json!(100.0), "{r}");
|
||||
assert_eq!(s.undo.len(), 1);
|
||||
|
||||
// Stood up first ((0..40, -20..0, 0..34)), then turned and moved.
|
||||
let mut d = Session::default();
|
||||
run(&mut d, "file.insert_step", json!({"path": path, "up": "y", "matrix": quarter}));
|
||||
let (lo, hi) = extent(&d);
|
||||
assert!(near(lo, [100.0, 0.0, 0.0]) && near(hi, [120.0, 40.0, 34.0]), "{lo:?} {hi:?}");
|
||||
|
||||
let mut d = Session::default();
|
||||
let e = d.execute("file.insert_step", &json!({"path": path, "matrix": [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0]})).unwrap_err().to_string();
|
||||
assert!(e.contains("not rigid"), "{e}");
|
||||
assert!(d.doc.features.is_empty() && d.doc.occurrences.is_empty());
|
||||
}
|
||||
|
||||
+32
-10
@@ -301,7 +301,8 @@ pub fn sketch_curve_visible(sk: &Sketch, i: usize) -> bool {
|
||||
}
|
||||
|
||||
/// Point roles shown and picked individually. Associated offsets keep their generated
|
||||
/// fit nodes private until their association is broken. Build once per sketch/frame.
|
||||
/// fit nodes private until their association is broken, except an open offset's two ends,
|
||||
/// which other curves snap and constrain to. Build once per sketch/frame.
|
||||
pub struct SketchPointVisibility<'a> {
|
||||
hidden: std::collections::HashSet<&'a str>,
|
||||
}
|
||||
@@ -321,7 +322,9 @@ impl<'a> SketchPointVisibility<'a> {
|
||||
let mut hidden: std::collections::HashSet<&str> =
|
||||
sk.points.iter().filter(|p| p.link.as_deref().is_some_and(|l| text.contains(l))).map(|p| p.id.as_str()).collect();
|
||||
for curve in sk.curves.iter().filter(|c| outputs.contains(c.id.as_str())) {
|
||||
hidden.extend(curve.kind.point_ids().into_iter().filter_map(|i| sk.points.get(i).map(|p| p.id.as_str())));
|
||||
let ends = curve.kind.ends();
|
||||
let interior = curve.kind.point_ids().into_iter().filter(|i| ends.is_none_or(|(a, b)| *i != a && *i != b));
|
||||
hidden.extend(interior.filter_map(|i| sk.points.get(i).map(|p| p.id.as_str())));
|
||||
}
|
||||
Self { hidden }
|
||||
}
|
||||
@@ -332,7 +335,7 @@ impl<'a> SketchPointVisibility<'a> {
|
||||
}
|
||||
|
||||
/// Should sketch point `i` be drawn or picked individually? Text outlines and derived
|
||||
/// offset fit nodes are private. Batch callers use [`SketchPointVisibility`].
|
||||
/// offset fit nodes (but an open offset's ends) are private. Batch callers use [`SketchPointVisibility`].
|
||||
pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
|
||||
sk.points.get(i).is_some_and(|p| SketchPointVisibility::new(sk).takes(p))
|
||||
}
|
||||
@@ -349,6 +352,20 @@ pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3
|
||||
scene_with_guides(s, cam, section, Guides::default())
|
||||
}
|
||||
|
||||
/// The plane of the sketch that is open, if any: it always shows a grid, whatever the Grid switch
|
||||
/// says (outside a sketch the grid is the user's choice).
|
||||
pub fn open_sketch_plane(s: &Session) -> Option<Plane> {
|
||||
let id = s.active_sketch?;
|
||||
s.world_state().sketch(id).map(|ss| ss.plane)
|
||||
}
|
||||
|
||||
/// The grid on a sketch plane: its centre (the view target's foot on the plane, snapped to the
|
||||
/// major step in the plane's own axes) and the plane's x and y directions.
|
||||
pub fn sketch_grid_frame(plane: &Plane, target: Vec3, major: f64) -> (Vec3, Vec3, Vec3) {
|
||||
let snap = |d: Vec3| d * (((target - plane.origin).dot(d) / major).round() * major);
|
||||
(plane.origin + snap(plane.x) + snap(plane.y), plane.x, plane.y)
|
||||
}
|
||||
|
||||
/// What a headless render draws besides the model: the ground grid and the origin's axes.
|
||||
/// Both are on by default; the grid lies on Z 0, so it cuts through a model that straddles it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -434,12 +451,17 @@ fn build_scene(
|
||||
if guides.grid {
|
||||
let (minor, major) = grid_step(cam.half_height());
|
||||
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
|
||||
let tf = cam.to_frame(cam.target);
|
||||
// Headless pictures keep the grid on the ground, the viewport's default.
|
||||
let (u, v) = (Vec3::X, Vec3::Y);
|
||||
let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major);
|
||||
let c = snap(u) + snap(v);
|
||||
let w = |p: Vec3| cam.from_frame(p);
|
||||
// Headless pictures keep the grid on the ground, the viewport's default; an open sketch
|
||||
// has its own plane's grid (world coordinates, no camera frame).
|
||||
let (c, u, v, w): (Vec3, Vec3, Vec3, Box<dyn Fn(Vec3) -> Vec3>) = if let Some(pl) = open_sketch_plane(s) {
|
||||
let (c, u, v) = sketch_grid_frame(&pl, cam.target, major);
|
||||
(c, u, v, Box::new(|p| p))
|
||||
} else {
|
||||
let tf = cam.to_frame(cam.target);
|
||||
let (u, v) = (Vec3::X, Vec3::Y);
|
||||
let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major);
|
||||
(snap(u) + snap(v), u, v, Box::new(|p| cam.from_frame(p)))
|
||||
};
|
||||
let n = ((ext / minor) as i64).clamp(1, 400);
|
||||
for i in -n..=n {
|
||||
let t = i as f64 * minor;
|
||||
@@ -528,7 +550,7 @@ pub fn section_segments(m: &solidcraft_geom::Mesh, o: Vec3, n: Vec3) -> Vec<(Vec
|
||||
}
|
||||
|
||||
/// Is a sketch drawn? The active one is; others unless hidden. Finished sketches are hidden
|
||||
/// once a feature uses them, like Fusion's default, unless shown one by one.
|
||||
/// once a feature uses them, by default, unless shown one by one.
|
||||
pub fn sketch_shown(s: &Session, id: u64) -> bool {
|
||||
sketch_drawn(s, id, true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Cursor distance is minimized within the design's freedom; lengths stay mandatory.
|
||||
#[path = "support/four_bar.rs"]
|
||||
mod four_bar;
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
fn drag(s: &mut solidcraft_engine::Session, target: Vec2) {
|
||||
let from = four_bar::point(s, "crank.end").unwrap();
|
||||
s.execute("sketch.drag", &json!({"entity":"crank.end","from":[from.x,from.y],"to":[target.x,target.y],"live":true})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_bar_large_off_locus_drag_reaches_the_closest_feasible_point() {
|
||||
for target in [Vec2::new(-10.0, 8.0), Vec2::new(-10.0, -8.0), Vec2::new(20.0, 20.0), Vec2::new(20.0, 0.0), Vec2::new(1000.0, -1000.0)] {
|
||||
let mut s = four_bar::session().unwrap();
|
||||
let side = four_bar::branch(&s).unwrap();
|
||||
drag(&mut s, target);
|
||||
let actual = four_bar::point(&s, "crank.end").unwrap();
|
||||
let nearest = target * (10.0 / target.len());
|
||||
assert!(actual.dist(nearest) < 1e-4, "target {target:?}, actual {actual:?}, expected {nearest:?}");
|
||||
assert!(four_bar::length_error(&s).unwrap() < 1e-6);
|
||||
assert_eq!(four_bar::branch(&s).unwrap(), side);
|
||||
let state = s.model.state();
|
||||
let report = &state.sketch(s.active_sketch.unwrap()).unwrap().report;
|
||||
assert!(report.ok());
|
||||
assert_eq!(report.dof, 1);
|
||||
s.execute("sketch.drag_end", &json!({})).unwrap();
|
||||
assert!(four_bar::point(&s, "crank.end").unwrap().dist(nearest) < 1e-4);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(four_bar::point(&s, "crank.end").unwrap().dist(Vec2::new(-10.0, 0.0)) < 1e-7);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_bar_reverse_motion_and_stationary_cursor_do_not_drift() {
|
||||
let mut s = four_bar::session().unwrap();
|
||||
let side = four_bar::branch(&s).unwrap();
|
||||
for target in [Vec2::new(-10.0, 16.0), Vec2::new(-10.0, -16.0), Vec2::new(0.0, 25.0), Vec2::new(0.0, -25.0)] {
|
||||
drag(&mut s, target);
|
||||
let at = four_bar::point(&s, "crank.end").unwrap();
|
||||
assert!(
|
||||
at.dist(target * (10.0 / target.len())) < 1e-4,
|
||||
"target {target:?}, actual {at:?}, length error {}",
|
||||
four_bar::length_error(&s).unwrap()
|
||||
);
|
||||
for _ in 0..8 {
|
||||
drag(&mut s, target);
|
||||
assert!(four_bar::point(&s, "crank.end").unwrap().dist(at) < 1e-4);
|
||||
assert!(four_bar::length_error(&s).unwrap() < 1e-6);
|
||||
assert_eq!(four_bar::branch(&s).unwrap(), side);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_bar_one_event_and_split_moves_agree_and_keep_the_branch() {
|
||||
let target = Vec2::new(-10.0, 16.0);
|
||||
let mut single = four_bar::session().unwrap();
|
||||
let mut split = four_bar::session().unwrap();
|
||||
let start = four_bar::point(&single, "crank.end").unwrap();
|
||||
let side = four_bar::branch(&single).unwrap();
|
||||
drag(&mut single, target);
|
||||
for i in 1..=16 {
|
||||
drag(&mut split, start + (target - start) * (i as f64 / 16.0));
|
||||
assert!(four_bar::length_error(&split).unwrap() < 1e-6);
|
||||
assert_eq!(four_bar::branch(&split).unwrap(), side);
|
||||
}
|
||||
assert!(four_bar::point(&single, "crank.end").unwrap().dist(four_bar::point(&split, "crank.end").unwrap()) < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_bar_live_cursor_motion_updates_driven_dimensions_before_release() {
|
||||
let mut s = four_bar::session().unwrap();
|
||||
s.execute("sketch.dimension", &json!({"entities": ["crank"], "type": "horizontal", "driven": true})).unwrap();
|
||||
let id = s.active_sketch.unwrap();
|
||||
let dimension = s.doc.sketch(id).unwrap().constraints.last().unwrap().id.clone();
|
||||
drag(&mut s, Vec2::new(-10.0, 8.0));
|
||||
let at = four_bar::point(&s, "crank.end").unwrap();
|
||||
assert!(at.dist(Vec2::new(-10.0, 8.0) * (10.0 / 164.0_f64.sqrt())) < 1e-4);
|
||||
let state = s.model.state();
|
||||
for sketch in [s.doc.sketch(id).unwrap(), &state.sketch(id).unwrap().sketch] {
|
||||
let c = sketch.constraints.iter().find(|c| c.id == dimension).unwrap();
|
||||
assert!(c.driven);
|
||||
assert!((c.kind.value().unwrap() - at.x.abs()).abs() < 1e-6, "reference dimension must measure the final live pose: {:?}", c.kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_bar_other_joint_follows_the_rocker_arc_and_its_limits() {
|
||||
let base = Vec2::new(42.0, 0.0);
|
||||
let lo = -(-1264.0_f64 / 1680.0).acos();
|
||||
let hi = -(0.2_f64).acos();
|
||||
for angle in [-2.6_f64, -2.1, -1.5, -1.1] {
|
||||
let mut s = four_bar::session().unwrap();
|
||||
let side = four_bar::branch(&s).unwrap();
|
||||
let from = four_bar::point(&s, "coupler.end").unwrap();
|
||||
let target = base + Vec2::new(angle.cos(), angle.sin()) * 60.0;
|
||||
let nearest = base + Vec2::new(angle.clamp(lo, hi).cos(), angle.clamp(lo, hi).sin()) * 20.0;
|
||||
s.execute("sketch.drag", &json!({"entity":"coupler.end","from":[from.x,from.y],"to":[target.x,target.y],"live":true})).unwrap();
|
||||
assert!(four_bar::point(&s, "coupler.end").unwrap().dist(nearest) < 1e-4, "angle {angle}");
|
||||
assert!(four_bar::length_error(&s).unwrap() < 1e-6);
|
||||
assert_eq!(four_bar::branch(&s).unwrap(), side);
|
||||
s.execute("sketch.drag_end", &json!({})).unwrap();
|
||||
assert!(four_bar::point(&s, "coupler.end").unwrap().dist(nearest) < 1e-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Public-safe 10/40/20/42 mm linkage, matching the reported one-degree-of-freedom sketch.
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
pub fn session() -> Result<Session> {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane":"XZ"}))?;
|
||||
let x = -10.0 + (1600.0 - 400.0 + 52.0 * 52.0) / 104.0;
|
||||
let y = -(1600.0_f64 - (x + 10.0) * (x + 10.0)).sqrt();
|
||||
s.execute("sketch.line", &json!({"points":[[0,0],[-10,0],[x,y],[42,0]],"ids":["crank","coupler","rocker"],"infer":false}))?;
|
||||
s.execute("sketch.line", &json!({"points":["crank.start","rocker.end"],"ids":["ground"],"infer":false}))?;
|
||||
for (line, length) in [("crank", 10.0), ("coupler", 40.0), ("rocker", 20.0), ("ground", 42.0)] {
|
||||
s.execute("sketch.dimension", &json!({"entities":[line],"value":length}))?;
|
||||
}
|
||||
s.execute("sketch.constraint.horizontal_vertical", &json!({"line":"ground"}))?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub fn point(s: &Session, name: &str) -> Result<Vec2> {
|
||||
let sk = s.doc.sketch(s.active_sketch.ok_or("missing active sketch")?)?;
|
||||
sk.resolve_point(name).and_then(|p| sk.point(p)).ok_or_else(|| format!("missing point {name}").into())
|
||||
}
|
||||
|
||||
pub fn length_error(s: &Session) -> Result<f64> {
|
||||
let mut error = 0.0_f64;
|
||||
for (a, b, length) in [
|
||||
("crank.start", "crank.end", 10.0),
|
||||
("coupler.start", "coupler.end", 40.0),
|
||||
("rocker.start", "rocker.end", 20.0),
|
||||
("ground.start", "ground.end", 42.0),
|
||||
] {
|
||||
error = error.max((point(s, a)?.dist(point(s, b)?) - length).abs());
|
||||
}
|
||||
error = error.max(point(s, "crank.start")?.len());
|
||||
error = error.max(point(s, "ground.end")?.dist(Vec2::new(42.0, 0.0)));
|
||||
Ok(error)
|
||||
}
|
||||
|
||||
pub fn branch(s: &Session) -> Result<f64> {
|
||||
let a = point(s, "crank.end")?;
|
||||
Ok((point(s, "rocker.end")? - a).cross(point(s, "coupler.end")? - a).signum())
|
||||
}
|
||||
@@ -532,6 +532,10 @@ impl NurbsSurface {
|
||||
pub fn homogeneous(&self) -> &[HPoint] {
|
||||
&self.ctrl
|
||||
}
|
||||
/// The control net.
|
||||
pub fn net(&self) -> crate::Grid<HPoint> {
|
||||
crate::Grid::checked(self.count, self.ctrl.clone())
|
||||
}
|
||||
/// Control point `(i, j)`.
|
||||
pub fn point(&self, i: usize, j: usize) -> HPoint {
|
||||
self.ctrl.get(j * self.count[0] + i).copied().unwrap_or(HPoint::ZERO)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! A rectangular net of values: a surface's control points, a sample grid. Rectangular by
|
||||
//! construction, so code that walks it never meets a missing cell: a grid is made from a
|
||||
//! function of `(i, j)`, from one row, or from rows checked once ([`Grid::from_u_rows`]).
|
||||
|
||||
/// `count[0]` values along u by `count[1]` along v; value `(i, j)` (i along u) is stored at
|
||||
/// `j·count[0] + i`, u fastest.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Grid<T> {
|
||||
count: [usize; 2],
|
||||
cells: Vec<T>,
|
||||
}
|
||||
|
||||
impl<T> Grid<T> {
|
||||
/// A grid of `f(i, j)` for every cell; `None` when `count[0]·count[1]` overflows.
|
||||
pub fn from_fn(count: [usize; 2], mut f: impl FnMut(usize, usize) -> T) -> Option<Grid<T>> {
|
||||
let total = count[0].checked_mul(count[1])?;
|
||||
let cu = count[0].max(1);
|
||||
Some(Grid { count, cells: (0..total).map(|k| f(k % cu, k / cu)).collect() })
|
||||
}
|
||||
|
||||
/// One row of values along u: `count` is `[cells.len(), 1]` (a curve's control points seen
|
||||
/// as a net).
|
||||
pub fn row(cells: Vec<T>) -> Grid<T> {
|
||||
Grid { count: [cells.len(), 1], cells }
|
||||
}
|
||||
|
||||
/// A grid whose caller already holds `count[0]·count[1]` cells (a checked surface's net).
|
||||
pub(crate) fn checked(count: [usize; 2], cells: Vec<T>) -> Grid<T> {
|
||||
Grid { count, cells }
|
||||
}
|
||||
|
||||
/// Values along u and along v.
|
||||
pub fn count(&self) -> [usize; 2] {
|
||||
self.count
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.cells.is_empty()
|
||||
}
|
||||
|
||||
/// Value `(i, j)`, i along u.
|
||||
pub fn get(&self, i: usize, j: usize) -> Option<&T> {
|
||||
if i < self.count[0] { self.cells.get(j * self.count[0] + i) } else { None }
|
||||
}
|
||||
|
||||
/// The values, u fastest.
|
||||
pub fn cells(&self) -> &[T] {
|
||||
&self.cells
|
||||
}
|
||||
|
||||
/// The runs along u, one per v index: run `j` holds values `(0, j)`, `(1, j)`, ….
|
||||
pub fn lines(&self) -> std::slice::Chunks<'_, T> {
|
||||
self.cells.chunks(self.count[0].max(1))
|
||||
}
|
||||
|
||||
/// The grid with u and v swapped: value `(i, j)` becomes value `(j, i)`.
|
||||
pub fn transposed(&self) -> Grid<T>
|
||||
where
|
||||
T: Clone,
|
||||
{
|
||||
let [cu, cv] = self.count;
|
||||
let cells = (0..self.cells.len()).filter_map(|k| self.get(k / cv.max(1), k % cv.max(1)).cloned()).collect();
|
||||
Grid { count: [cv, cu], cells }
|
||||
}
|
||||
|
||||
/// The values to change in place; the grid keeps its shape.
|
||||
pub fn cells_mut(&mut self) -> &mut [T] {
|
||||
&mut self.cells
|
||||
}
|
||||
|
||||
pub fn into_cells(self) -> Vec<T> {
|
||||
self.cells
|
||||
}
|
||||
|
||||
pub fn map<U>(self, f: impl FnMut(T) -> U) -> Grid<U> {
|
||||
Grid { count: self.count, cells: self.cells.into_iter().map(f).collect() }
|
||||
}
|
||||
|
||||
/// [`Grid::map`] with a step that can fail: the first failure.
|
||||
pub fn try_map<U, E>(self, f: impl FnMut(T) -> Result<U, E>) -> Result<Grid<U>, E> {
|
||||
Ok(Grid { count: self.count, cells: self.cells.into_iter().map(f).collect::<Result<_, _>>()? })
|
||||
}
|
||||
|
||||
/// The grid as rows, one per u index: `rows[i][j]` is value `(i, j)` (truck's layout).
|
||||
pub fn into_u_rows(self) -> Vec<Vec<T>> {
|
||||
let cu = self.count[0];
|
||||
let mut rows: Vec<Vec<T>> = (0..cu).map(|_| Vec::with_capacity(self.count[1])).collect();
|
||||
for (k, v) in self.cells.into_iter().enumerate() {
|
||||
if let Some(row) = rows.get_mut(k % cu) {
|
||||
row.push(v);
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> Grid<T> {
|
||||
/// A grid from rows, one per u index: `rows[i][j]` is value `(i, j)` (truck's layout).
|
||||
/// `None` when the rows differ in length, the one check rows from elsewhere need.
|
||||
pub fn from_u_rows<R: AsRef<[T]>>(rows: &[R]) -> Option<Grid<T>> {
|
||||
let cv = rows.first().map_or(0, |r| r.as_ref().len());
|
||||
if rows.iter().any(|r| r.as_ref().len() != cv) {
|
||||
return None;
|
||||
}
|
||||
let cells = (0..cv).flat_map(|j| rows.iter().filter_map(move |r| r.as_ref().get(j).cloned())).collect();
|
||||
Some(Grid { count: [rows.len(), cv], cells })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rows_go_in_and_come_back_out() {
|
||||
// rows[i][j]: two along u, three along v.
|
||||
let rows = vec![vec![(0, 0), (0, 1), (0, 2)], vec![(1, 0), (1, 1), (1, 2)]];
|
||||
let g = Grid::from_u_rows(&rows).unwrap();
|
||||
assert_eq!(g.count(), [2, 3]);
|
||||
assert_eq!(g.cells(), &[(0, 0), (1, 0), (0, 1), (1, 1), (0, 2), (1, 2)]);
|
||||
assert_eq!(g.get(1, 2), Some(&(1, 2)));
|
||||
assert_eq!(g.get(2, 0), None);
|
||||
assert_eq!(g.into_u_rows(), rows);
|
||||
assert_eq!(Grid::row(vec![1, 2, 3]).count(), [3, 1]);
|
||||
let g = Grid::from_fn([3, 2], |i, j| (i, j)).unwrap();
|
||||
assert_eq!(g.lines().collect::<Vec<_>>(), vec![&[(0, 0), (1, 0), (2, 0)][..], &[(0, 1), (1, 1), (2, 1)][..]]);
|
||||
let t = g.transposed();
|
||||
assert_eq!(t.count(), [2, 3]);
|
||||
assert!((0..3).all(|i| (0..2).all(|j| t.get(j, i) == g.get(i, j))));
|
||||
assert!(Grid::from_fn([usize::MAX, 2], |_, _| 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_rectangles_are_grids() {
|
||||
assert!(Grid::from_u_rows(&[vec![1, 2], vec![3]]).is_none());
|
||||
let empty: [Vec<u8>; 0] = [];
|
||||
assert!(Grid::from_u_rows(&empty).is_some_and(|g| g.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_map_stops_at_the_first_failure() {
|
||||
assert_eq!(Grid::row(vec![1, -1, -2]).try_map(|x| if x > 0 { Ok(x) } else { Err(x) }), Err(-1));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ pub mod compare;
|
||||
pub mod env;
|
||||
pub mod fill;
|
||||
pub mod fill_general;
|
||||
mod grid;
|
||||
mod mesh;
|
||||
pub mod mesh_distance;
|
||||
pub mod network;
|
||||
@@ -27,14 +28,17 @@ pub mod section;
|
||||
pub mod spline_space;
|
||||
pub mod tess;
|
||||
pub mod tspline;
|
||||
mod unit_vec3;
|
||||
mod vec;
|
||||
|
||||
pub use arcfit::MAX_FIT_PIECES;
|
||||
pub use blend::hermite_bezier;
|
||||
pub use bvh::{Bvh, MeshIndex};
|
||||
pub use grid::Grid;
|
||||
pub use mesh::{Aabb3, BoxedTriangle, InsideIndex, MassProperties, Mesh, MeshMeasure, find_close_pair};
|
||||
pub use plane::Plane;
|
||||
pub use profile::{Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, bezier, regions_apart};
|
||||
pub use unit_vec3::UnitVec3;
|
||||
pub use vec::{Vec2, Vec3};
|
||||
|
||||
/// Geometric tolerance for coincidence tests (mm).
|
||||
|
||||
@@ -31,8 +31,8 @@ impl Plane {
|
||||
}
|
||||
|
||||
/// Plane through `origin` with normal `n` (in-plane axes chosen deterministically).
|
||||
pub fn from_normal(origin: Vec3, n: Vec3) -> Option<Plane> {
|
||||
let n = n.normalized()?;
|
||||
pub fn from_normal(origin: Vec3, n: impl Into<Vec3>) -> Option<Plane> {
|
||||
let n = n.into().normalized()?;
|
||||
// Horizontal planes keep world X as sketch x; others use the horizontal direction Z × n.
|
||||
let cand = if n.dot(Vec3::Z).abs() > 0.9 { Vec3::X } else { Vec3::Z.cross(n) };
|
||||
let x = (cand - n * cand.dot(n)).normalized()?;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! A unit vector: a 3D vector of length 1.
|
||||
|
||||
use std::ops::{Add, Deref, Mul, Neg, Sub};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Vec3;
|
||||
|
||||
/// A unit vector: length 1 up to rounding. Made only from a vector with a length
|
||||
/// ([`UnitVec3::new`], [`Vec3::unit`]), so a function taking one need not normalise it or
|
||||
/// handle a zero vector again. It reads as the [`Vec3`] it holds (`d.dot(v)`, `*d`), and
|
||||
/// [`Vec3::dot`] and [`Vec3::cross`] take one.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(into = "Vec3", try_from = "Vec3")]
|
||||
pub struct UnitVec3(Vec3);
|
||||
|
||||
impl UnitVec3 {
|
||||
pub const X: UnitVec3 = UnitVec3(Vec3::X);
|
||||
pub const Y: UnitVec3 = UnitVec3(Vec3::Y);
|
||||
pub const Z: UnitVec3 = UnitVec3(Vec3::Z);
|
||||
|
||||
/// `v` scaled to length 1; `None` when it has no length (or is not finite).
|
||||
pub fn new(v: Vec3) -> Option<UnitVec3> {
|
||||
v.normalized().map(UnitVec3)
|
||||
}
|
||||
pub fn vec(self) -> Vec3 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Vec3 {
|
||||
/// This vector scaled to length 1 ([`UnitVec3::new`]).
|
||||
pub fn unit(self) -> Option<UnitVec3> {
|
||||
UnitVec3::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for UnitVec3 {
|
||||
type Target = Vec3;
|
||||
fn deref(&self) -> &Vec3 {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UnitVec3> for Vec3 {
|
||||
fn from(d: UnitVec3) -> Vec3 {
|
||||
d.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Vec3> for UnitVec3 {
|
||||
type Error = &'static str;
|
||||
fn try_from(v: Vec3) -> Result<UnitVec3, Self::Error> {
|
||||
UnitVec3::new(v).ok_or("a unit vector needs a finite vector with a length")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&UnitVec3> for Vec3 {
|
||||
fn from(d: &UnitVec3) -> Vec3 {
|
||||
d.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Neg for UnitVec3 {
|
||||
type Output = UnitVec3;
|
||||
fn neg(self) -> UnitVec3 {
|
||||
UnitVec3(-self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// `+` and `-` with a [`Vec3`] or another unit vector, giving a [`Vec3`].
|
||||
macro_rules! sum_ops {
|
||||
($($l:ty, $r:ty);*) => {$(
|
||||
impl Add<$r> for $l {
|
||||
type Output = Vec3;
|
||||
fn add(self, o: $r) -> Vec3 {
|
||||
Vec3::from(self) + Vec3::from(o)
|
||||
}
|
||||
}
|
||||
impl Sub<$r> for $l {
|
||||
type Output = Vec3;
|
||||
fn sub(self, o: $r) -> Vec3 {
|
||||
Vec3::from(self) - Vec3::from(o)
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
sum_ops!(UnitVec3, Vec3; Vec3, UnitVec3; UnitVec3, UnitVec3);
|
||||
|
||||
impl Mul<f64> for UnitVec3 {
|
||||
type Output = Vec3;
|
||||
fn mul(self, s: f64) -> Vec3 {
|
||||
self.0 * s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_unit_vector_has_length_one() {
|
||||
let d = Vec3::new(0.0, 3.0, 4.0).unit().unwrap();
|
||||
assert_eq!(*d, Vec3::new(0.0, 0.6, 0.8));
|
||||
assert_eq!(d * 5.0, Vec3::new(0.0, 3.0, 4.0));
|
||||
assert_eq!(*-d, Vec3::new(0.0, -0.6, -0.8));
|
||||
assert_eq!(d.dot(Vec3::Z), 0.8);
|
||||
assert_eq!(UnitVec3::Z.cross(UnitVec3::X), Vec3::Y);
|
||||
assert_eq!(Vec3::X.dot(d), 0.0);
|
||||
assert_eq!(Vec3::X + d, Vec3::new(1.0, 0.6, 0.8));
|
||||
assert!((d - UnitVec3::Z).dist(Vec3::new(0.0, 0.6, -0.2)) < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_length_no_unit_vector() {
|
||||
assert_eq!(Vec3::ZERO.unit(), None);
|
||||
assert_eq!(Vec3::new(f64::NAN, 0.0, 1.0).unit(), None);
|
||||
assert_eq!(Vec3::new(f64::INFINITY, 0.0, 0.0).unit(), None);
|
||||
}
|
||||
}
|
||||
@@ -96,10 +96,14 @@ impl Vec3 {
|
||||
pub const fn new(x: f64, y: f64, z: f64) -> Self {
|
||||
Vec3 { x, y, z }
|
||||
}
|
||||
pub fn dot(self, o: Vec3) -> f64 {
|
||||
/// Takes a [`Vec3`] or a [`UnitVec3`](crate::UnitVec3).
|
||||
pub fn dot(self, o: impl Into<Vec3>) -> f64 {
|
||||
let o = o.into();
|
||||
self.x * o.x + self.y * o.y + self.z * o.z
|
||||
}
|
||||
pub fn cross(self, o: Vec3) -> Vec3 {
|
||||
/// Takes a [`Vec3`] or a [`UnitVec3`](crate::UnitVec3).
|
||||
pub fn cross(self, o: impl Into<Vec3>) -> Vec3 {
|
||||
let o = o.into();
|
||||
Vec3::new(self.y * o.z - self.z * o.y, self.z * o.x - self.x * o.z, self.x * o.y - self.y * o.x)
|
||||
}
|
||||
pub fn len(self) -> f64 {
|
||||
|
||||
@@ -325,6 +325,7 @@ fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, acces
|
||||
access,
|
||||
outline: true,
|
||||
eye: [eye[0], eye[1], eye[2], if cam.fov > 0.0 { 1.0 } else { 0.0 }],
|
||||
ao_radius: 0.0,
|
||||
ssaa: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
+16
-4
@@ -14,14 +14,17 @@ use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::pbr;
|
||||
|
||||
mod ao;
|
||||
|
||||
/// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8).
|
||||
pub const TRI_SIZE: usize = 28;
|
||||
pub const PBR_SIZE: usize = pbr::VERTEX_SIZE;
|
||||
/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32).
|
||||
pub const LINE_SIZE: usize = 32;
|
||||
/// The shader's `U`: the view-projection matrix, 9 `vec4`s, the 8 analysis colours, their
|
||||
/// flags and the depth direction (`uniform_bytes`).
|
||||
const UNIFORM_SIZE: u64 = 368;
|
||||
/// The shader's `U`: the view-projection matrix, 10 `vec4`s (the tenth carries the ambient
|
||||
/// occlusion radius), the 8 analysis colours, their flags and the depth direction
|
||||
/// (`uniform_bytes`).
|
||||
const UNIFORM_SIZE: u64 = 384;
|
||||
|
||||
/// A body drawn from its display plan, evaluated on the GPU: the plan, and a colour per face
|
||||
/// (the plan's face numbering; faces past the end take the last colour).
|
||||
@@ -245,6 +248,8 @@ pub struct Frame {
|
||||
/// Eye position and 1 for a perspective camera (0: orthographic), for the section cap's depth
|
||||
/// and the depth bias.
|
||||
pub eye: [f32; 4],
|
||||
/// World-space occlusion radius; zero skips the depth prepass and all AO sampling.
|
||||
pub ao_radius: f32,
|
||||
/// Supersampling (#1288): draw the frame this many times the viewport's pixels in each
|
||||
/// direction and filter it down (1: off). Under 1 the frame is drawn smaller and filtered up
|
||||
/// (the product preview past its pixel budget, #1464).
|
||||
@@ -541,6 +546,8 @@ impl Batches {
|
||||
|
||||
/// The viewport's pipelines and the scenes uploaded to one device, for one kind of target.
|
||||
pub struct Renderer {
|
||||
/// The AO depth prepass resources (a 1 × 1 stand-in while ambient occlusion is off).
|
||||
ao: ao::Resources,
|
||||
tri: wgpu::RenderPipeline,
|
||||
/// `tri` and `tri_cut` for planned bodies (positions and normals from the GPU evaluation,
|
||||
/// colours per vertex, indexed).
|
||||
@@ -959,9 +966,10 @@ impl Renderer {
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let ao = ao::Resources::new(device, &bgl, &module, reversed);
|
||||
let solid_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("sc_solid"),
|
||||
bind_group_layouts: &[Some(&bgl), Some(&access_bgl)],
|
||||
bind_group_layouts: &[Some(&bgl), Some(&access_bgl), Some(&ao.layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let built = [Some(tri_layout())];
|
||||
@@ -1108,6 +1116,7 @@ impl Renderer {
|
||||
})
|
||||
});
|
||||
Renderer {
|
||||
ao,
|
||||
depth_reset,
|
||||
target: t,
|
||||
max_texture: device.limits().max_texture_dimension_2d,
|
||||
@@ -1305,6 +1314,7 @@ impl Renderer {
|
||||
res.access_bind = access_texture(device, Some(queue), &res.access_bgl, a.size, &a.depth);
|
||||
res.access_version = a.version;
|
||||
}
|
||||
res.ao.prepare(device, encoder, &res.bind, cb.ao_radius, cb.size_px, res.target.reversed_depth(), [&res.model, &res.preview]);
|
||||
// Images: upload new or changed textures, drop removed ones, rebuild the quads.
|
||||
res.textures.retain(|id, _| cb.images.iter().any(|i| i.id == *id));
|
||||
res.quads.clear();
|
||||
@@ -1414,6 +1424,7 @@ impl Renderer {
|
||||
lines(pass, &res.overlay, &m.under);
|
||||
lines(pass, &res.overlay, &sk.under);
|
||||
pass.set_bind_group(1, &res.access_bind, &[]);
|
||||
pass.set_bind_group(2, &res.ao.bind, &[]);
|
||||
let planned = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline| {
|
||||
for d in &m.planned {
|
||||
pass.set_pipeline(p);
|
||||
@@ -1570,6 +1581,7 @@ pub fn uniform_bytes(cb: &Frame, w: f32, h: f32, linear: bool, reversed: bool) -
|
||||
None => v.extend([0.0; 8]),
|
||||
}
|
||||
v.extend(cb.eye);
|
||||
v.extend([cb.ao_radius, 0.0, 0.0, 0.0]);
|
||||
v.extend(cb.analysis_colors);
|
||||
v.extend([if reversed { 1.0 } else { 0.0 }, FACE_STEPS as f32, FACE_SLOPE, 0.0]);
|
||||
v.iter().flat_map(|x| x.to_le_bytes()).collect()
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! The optional AO depth prepass. Only opaque model and preview triangles participate: their
|
||||
//! depth is drawn once into a single-sample depth map the lit fragment samples for occlusion.
|
||||
//! The map is independent of the window's MSAA, and the depth runs the same way as the
|
||||
//! viewport's own buffer (see [`super::depth_compare`] and [`super::far_depth`]).
|
||||
|
||||
use super::{Batches, color_layout, depth_compare, far_depth, planned_layout, tri_layout};
|
||||
|
||||
pub(super) struct Resources {
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
pub bind: wgpu::BindGroup,
|
||||
depth: wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
planned_pipeline: wgpu::RenderPipeline,
|
||||
}
|
||||
|
||||
fn texture(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, size: [u32; 2]) -> (wgpu::TextureView, wgpu::BindGroup) {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("sc_ao_depth"),
|
||||
size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let depth = texture.create_view(&Default::default());
|
||||
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("sc_ao_depth"),
|
||||
layout,
|
||||
entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&depth) }],
|
||||
});
|
||||
(depth, bind)
|
||||
}
|
||||
|
||||
impl Resources {
|
||||
pub fn new(device: &wgpu::Device, uniform: &wgpu::BindGroupLayout, module: &wgpu::ShaderModule, reversed: bool) -> Self {
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("sc_ao_depth"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("sc_ao_prepass"),
|
||||
bind_group_layouts: &[Some(uniform)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let built = [Some(tri_layout())];
|
||||
let evaluated = [Some(planned_layout()), Some(color_layout())];
|
||||
let pipeline = |planned: bool| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("sc_ao_prepass"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module,
|
||||
entry_point: Some("vs_tri"),
|
||||
buffers: if planned { &evaluated } else { &built },
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(depth_compare(reversed, wgpu::CompareFunction::Less)),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module,
|
||||
entry_point: Some("fs_ao_depth"),
|
||||
targets: &[],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let planned_pipeline = pipeline(true);
|
||||
let pipeline = pipeline(false);
|
||||
let (depth, bind) = texture(device, &layout, [1, 1]);
|
||||
Self { layout, bind, depth, size: [1, 1], pipeline, planned_pipeline }
|
||||
}
|
||||
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uniform: &wgpu::BindGroup,
|
||||
radius: f32,
|
||||
size_px: [f32; 2],
|
||||
reversed: bool,
|
||||
batches: [&Batches; 2],
|
||||
) {
|
||||
let enabled = radius.is_finite() && radius > 0.0;
|
||||
// Bound memory at large window sizes; normalized coordinates also handle downsampling.
|
||||
let size = target_size(enabled, size_px, device.limits().max_texture_dimension_2d);
|
||||
if size != self.size {
|
||||
(self.depth, self.bind) = texture(device, &self.layout, size);
|
||||
self.size = size;
|
||||
}
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("sc_ao_prepass"),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.depth,
|
||||
depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(far_depth(reversed)), store: wgpu::StoreOp::Store }),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
pass.set_bind_group(0, uniform, &[]);
|
||||
for batch in batches {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
if let Some(tris) = &batch.tris {
|
||||
for (buffer, count) in &tris.parts {
|
||||
pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
pass.draw(0..*count, 0..1);
|
||||
}
|
||||
}
|
||||
pass.set_pipeline(&self.planned_pipeline);
|
||||
for draw in &batch.planned {
|
||||
pass.set_vertex_buffer(0, draw.mesh.vertices.slice(..));
|
||||
pass.set_vertex_buffer(1, draw.colors.slice(..));
|
||||
pass.set_index_buffer(draw.mesh.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..draw.mesh.index_count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn target_size(enabled: bool, pixels: [f32; 2], device_limit: u32) -> [u32; 2] {
|
||||
if !enabled {
|
||||
return [1, 1];
|
||||
}
|
||||
let limit = device_limit.clamp(1, 2048);
|
||||
let pixels = pixels.map(|s| if s.is_finite() { s.max(1.0) } else { 1.0 });
|
||||
let scale = (limit as f32 / pixels[0].max(pixels[1])).min(1.0);
|
||||
pixels.map(|s| (s * scale).ceil().clamp(1.0, limit as f32) as u32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::target_size;
|
||||
#[test]
|
||||
fn depth_map_is_bounded_and_released_when_disabled() {
|
||||
assert_eq!(target_size(true, [6000.0, 3000.0], 8192), [2048, 1024]);
|
||||
assert_eq!(target_size(true, [6000.0, 3000.0], 1024), [1024, 512]);
|
||||
assert_eq!(target_size(false, [6000.0, 3000.0], 8192), [1, 1]);
|
||||
assert_eq!(target_size(true, [f32::NAN, f32::INFINITY], 8192), [1, 1]);
|
||||
assert_eq!(target_size(true, [0.0, -1.0], 8192), [1, 1]);
|
||||
}
|
||||
}
|
||||
@@ -492,6 +492,12 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
|
||||
(pts.first().copied().unwrap_or_default(), len)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Solids [`Body::new`] meshed on this thread to orient them (tests count the work an import does).
|
||||
pub(crate) static ORIENTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Deflection the orientation mesh uses: coarse enough for a large solid, fine enough that a
|
||||
/// small one still tessellates. `unmeshed_faces` counts at the same value, so the count the
|
||||
/// orientation mesh produced is reused instead of meshing the body again.
|
||||
@@ -559,13 +565,17 @@ impl Body {
|
||||
Ok((volume, missing))
|
||||
})
|
||||
};
|
||||
let (mut v, mut missing) = orient(tol)?;
|
||||
#[cfg(test)]
|
||||
ORIENTED.with(|n| n.set(n.get() + 1));
|
||||
// The faces missing from the coarse mesh are the same whichever way the solid turns out
|
||||
// to face, so the count is kept for `unmeshed_faces` either way.
|
||||
let (mut v, missing) = orient(tol)?;
|
||||
// A long thin round part (a through hole's tool: a cylinder whose vertices all lie on
|
||||
// its seam, so they span its length only) meshed that coarsely can come out with a
|
||||
// negative volume when it is outward already: check finely before turning it inside out.
|
||||
let fine = (tol * 0.02).max(1e-4);
|
||||
if v < 0.0 && fine < tol {
|
||||
(v, missing) = orient(fine)?;
|
||||
(v, _) = orient(fine)?;
|
||||
}
|
||||
if v < 0.0 {
|
||||
solid.not();
|
||||
@@ -574,7 +584,7 @@ impl Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: (v >= 0.0).then_some((tol, missing)),
|
||||
coarse_missing: Some((tol, missing)),
|
||||
mesh: None,
|
||||
color: None,
|
||||
paint: None,
|
||||
@@ -595,7 +605,15 @@ impl Body {
|
||||
if solid.boundaries().is_empty() {
|
||||
return Err(KernelError::Failed("empty result".into()));
|
||||
}
|
||||
let mut solid = rephase_revolved(solid);
|
||||
Body::placed_as_is(rephase_revolved(solid), flip, coarse)
|
||||
}
|
||||
|
||||
/// [`Body::new_placed`] for a solid whose revolved faces were already turned away from
|
||||
/// their seam (a placement of an oriented body keeps them where they were).
|
||||
fn placed_as_is(mut solid: Solid, flip: bool, coarse: Option<(f64, usize)>) -> Result<Body> {
|
||||
if solid.boundaries().is_empty() {
|
||||
return Err(KernelError::Failed("empty result".into()));
|
||||
}
|
||||
let span = span_of(&solid);
|
||||
if flip {
|
||||
solid.not();
|
||||
@@ -616,6 +634,23 @@ impl Body {
|
||||
})
|
||||
}
|
||||
|
||||
/// This oriented body's solid moved by `m` (an affine placement), as [`Body::new_placed`]
|
||||
/// makes it: the orientation comes from the matrix, so a part placed many times (assembly
|
||||
/// instances) is meshed to orient it only once. Topology only; appearance is the caller's.
|
||||
pub(crate) fn placed(&self, m: &mt::Matrix4) -> Result<Body> {
|
||||
use mt::SquareMatrix as _;
|
||||
if *m == mt::Matrix4::from_scale(1.0) {
|
||||
return Ok(self.clone());
|
||||
}
|
||||
let a = [[m.x.x, m.x.y, m.x.z, m.x.w], [m.y.x, m.y.y, m.y.z, m.y.w], [m.z.x, m.z.y, m.z.z, m.z.w], [m.w.x, m.w.y, m.w.z, m.w.w]];
|
||||
let det = m.determinant();
|
||||
if !det.is_finite() || det.abs() < 1e-12 {
|
||||
return Err(KernelError::Invalid("degenerate placement".into()));
|
||||
}
|
||||
let rigid = crate::ops::rigid_placement(&a);
|
||||
Body::placed_as_is(mt::builder::transformed(&*self.solid, *m), det < 0.0, rigid.then_some(self.coarse_missing).flatten())
|
||||
}
|
||||
|
||||
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
|
||||
self.regularization_protected |= input.regularization_protected;
|
||||
self
|
||||
@@ -1254,7 +1289,7 @@ impl Body {
|
||||
if self.is_mesh() {
|
||||
return 0;
|
||||
}
|
||||
let tol = (self.size() * 1e-2).max(1e-3);
|
||||
let tol = orientation_tolerance(self.size());
|
||||
if let Some((cached_tol, missing)) = self.coarse_missing
|
||||
&& cached_tol == tol
|
||||
{
|
||||
@@ -1365,6 +1400,29 @@ impl Body {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Edges `indices` (as in [`Body::tessellate`]'s `edges`) in `plane`'s coordinates,
|
||||
/// exactly: lines, circular arcs, and free-form curves as the Bézier spans they are, for a
|
||||
/// sketch to project them. `None` for an edge that doesn't lie in a plane parallel to
|
||||
/// `plane`, isn't read exactly or isn't there, and for every edge of a mesh body.
|
||||
pub fn planar_edges(&self, plane: &solidcraft_geom::Plane, indices: &[usize]) -> Vec<Option<Vec<solidcraft_geom::Seg2>>> {
|
||||
let none = || vec![None; indices.len()];
|
||||
if self.is_mesh() {
|
||||
return none();
|
||||
}
|
||||
let tol = (self.size() * 1e-7).max(1e-9) * 10.0;
|
||||
guard("planar edges", || {
|
||||
let edges = Self::unique_edges(&self.solid);
|
||||
Ok(indices
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let mut work = 262_144usize;
|
||||
edges.get(*i).and_then(|e| crate::prism_curve::projected(e, plane, tol, &mut work))
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.unwrap_or_else(|_| none())
|
||||
}
|
||||
|
||||
/// Faces with area and centroid.
|
||||
pub fn faces(&self, tol: f64) -> Result<Vec<FaceInfo>> {
|
||||
let m = self.tessellate(tol)?;
|
||||
|
||||
@@ -18,6 +18,8 @@ pub enum BooleanStrategy {
|
||||
GrownBoss,
|
||||
PlanarFallback,
|
||||
ToolNudge,
|
||||
/// A rotationally symmetric tool turned about its own axis; its volume is unchanged.
|
||||
ToolReseam,
|
||||
Piecewise,
|
||||
/// No retry gave a result, and the answer from sample points was kept although the bodies'
|
||||
/// facetings disagree with it.
|
||||
@@ -61,6 +63,7 @@ impl BooleanFallback {
|
||||
BooleanStrategy::GrownBoss => format!("boss grown {:.6e} mm into the material around the bore it fills", self.face_shift_mm),
|
||||
BooleanStrategy::PlanarFallback => "exact planar fallback (no displacement)".into(),
|
||||
BooleanStrategy::ToolNudge => format!("tool displacement {:.6e} mm, rotation {:.6e} rad", self.translation_mm, self.rotation_rad),
|
||||
BooleanStrategy::ToolReseam => format!("axisymmetric tool reseamed by {:.6e} rad (no displacement)", self.rotation_rad),
|
||||
BooleanStrategy::Piecewise => "piecewise retry (no displacement)".into(),
|
||||
BooleanStrategy::UnverifiedShortcut => format!(
|
||||
"not verified: the bodies' facets pass through each other by more than {:.6e} mm, but they could not be intersected there; that part is ignored",
|
||||
|
||||
@@ -3,14 +3,22 @@
|
||||
//! cylinder recut from silently removing an additional material boundary that
|
||||
//! the fallback's coarse material samples do not see.
|
||||
use crate::body::{Body, from_p3};
|
||||
use solidcraft_geom::{Plane, Seg2, Vec3};
|
||||
use solidcraft_geom::{Plane, Seg2, UnitVec3, Vec3};
|
||||
use std::collections::HashSet;
|
||||
use truck_modeling as mt;
|
||||
|
||||
pub(super) fn boundaries_clear(body: &Body, bore_faces: &[usize], origin: Vec3, axis: Vec3, radius: f64, lo: f64, hi: f64, tol: f64) -> Option<()> {
|
||||
pub(super) fn boundaries_clear(
|
||||
body: &Body,
|
||||
bore_faces: &[usize],
|
||||
origin: Vec3,
|
||||
axis: UnitVec3,
|
||||
radius: f64,
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
tol: f64,
|
||||
) -> Option<()> {
|
||||
let plane = Plane::from_normal(origin, axis)?;
|
||||
if !origin.is_finite()
|
||||
|| !axis.is_finite()
|
||||
|| ![radius, lo, hi, tol].iter().all(|v| v.is_finite())
|
||||
|| radius <= tol
|
||||
|| hi <= lo
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use crate::*;
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, UnitVec3, Vec2, Vec3};
|
||||
|
||||
fn fixture() -> (Body, Body, Vec3, f64) {
|
||||
let slope = 1f64.to_radians().tan();
|
||||
@@ -113,7 +113,7 @@ fn unproved_curved_boundary_between_clear_endpoints_refuses_recut() {
|
||||
assert!(a.len() > 5. && b.len() > 5., "endpoint samples miss the interior excursion");
|
||||
assert!(body.tessellate_with(1e-4, true).unwrap().contains(Vec3::new(4.5, 0., 16.06)));
|
||||
let before = step_export(&[&body], "curved source").unwrap();
|
||||
assert!(crate::bore_guard::boundaries_clear(&body, &[], Vec3::ZERO, Vec3::Z, 5., 14., 24., 1e-6).is_none());
|
||||
assert!(crate::bore_guard::boundaries_clear(&body, &[], Vec3::ZERO, UnitVec3::Z, 5., 14., 24., 1e-6).is_none());
|
||||
assert_eq!(step_export(&[&body], "curved source").unwrap(), before);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user