Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b685b7301f | ||
|
|
8f768b65c8 | ||
|
|
932efbf341 | ||
|
|
b8d7b42808 | ||
|
|
a8e8d82f90 | ||
|
|
33cd031701 | ||
|
|
6422ef84d5 | ||
|
|
7c9f8a5895 | ||
|
|
36b6496ad5 | ||
|
|
48986041ed | ||
|
|
c39bd23d07 | ||
|
|
a33c5a7861 | ||
|
|
5c95b0496d | ||
|
|
72aa77dabd | ||
|
|
243db6cb8f | ||
|
|
83c31d77c4 | ||
|
|
ae6a33bf96 | ||
|
|
675c865431 | ||
|
|
2f31138f9d | ||
|
|
8c503ad1be | ||
|
|
dc40f051b1 | ||
|
|
712b427a9c | ||
|
|
e48e553758 | ||
|
|
dc8edead22 | ||
|
|
2acc344aa4 | ||
|
|
487aa64e10 | ||
|
|
180e62c981 | ||
|
|
11a196e6f8 | ||
|
|
a11446fd17 | ||
|
|
7a3221d2dd | ||
|
|
e8776d8583 | ||
|
|
cb857f1fa7 | ||
|
|
e347f820e6 | ||
|
|
6597ffe072 | ||
|
|
4a70c03deb | ||
|
|
f480898158 | ||
|
|
9be8bb5e6b | ||
|
|
843fa0dac6 | ||
|
|
2c924fd385 | ||
|
|
8be736c33e | ||
|
|
f3494efbbb | ||
|
|
6fe16b790b | ||
|
|
979614e60f | ||
|
|
8a31738fe5 | ||
|
|
f7343e9161 |
No files matched your search
@@ -1,5 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Questions and chat
|
||||
url: https://solidcraft.io/discord
|
||||
url: https://discord.gg/FtU4NQNVDH
|
||||
about: Ask questions and talk with users and contributors on the SolidCraft Discord.
|
||||
@@ -1,3 +1,4 @@
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- self-hosted-fast
|
||||
- self-hosted-light
|
||||
+119
-40
@@ -10,14 +10,22 @@
|
||||
# and app startup) run on pull requests only when what they test changes (the GPU crate, the app,
|
||||
# the viewport's GPU glue, the graphics dependencies, their scripts or this file); main and
|
||||
# nightly always run them. Only main saves the build caches; pull requests restore them.
|
||||
# The merge queue runs the full suite on exactly the commit it then puts on main, so a push to
|
||||
# main that a passing merge-queue run already tested runs only the repository checks; a push
|
||||
# that skipped the queue still runs everything. Pull requests run the reference tests when the
|
||||
# command line (or anything it depends on) changes, and doc tests for the affected packages.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
# Pushes to main and merge-queue groups use both pools: Clippy, the doc tests and test shards
|
||||
# 1-2 run on the self-hosted runner (`self-hosted-fast`, warm build directories via
|
||||
# scripts/ci/warm-target.sh), everything else on GitHub's runners. The test archive is 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). Pull requests, the nightly run and Run workflow use GitHub's runners only; the
|
||||
# self-hosted runner refuses any job that isn't a push to main or a merge-queue group.
|
||||
# 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); everything else runs on GitHub's runners.
|
||||
# The test archive is always built on GitHub's runners: the self-hosted machine has a newer
|
||||
# glibc, so its binaries wouldn't start on ubuntu-24.04, while the archive runs there unchanged
|
||||
# (same /home/runner/work checkout path). Fork pull requests, the nightly run and Run workflow
|
||||
# use GitHub's runners only; the self-hosted runners refuse any other job.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
@@ -63,13 +71,23 @@ jobs:
|
||||
|
||||
changes:
|
||||
name: Select checks
|
||||
runs-on: ubuntu-24.04
|
||||
# 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:
|
||||
contents: read
|
||||
# To ask whether the merge queue already tested a commit pushed to main.
|
||||
actions: read
|
||||
outputs:
|
||||
native: ${{ steps.paths.outputs.native }}
|
||||
kernel: ${{ steps.paths.outputs.kernel }}
|
||||
windows: ${{ steps.paths.outputs.windows }}
|
||||
full: ${{ steps.paths.outputs.full }}
|
||||
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) }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -80,13 +98,30 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
native=false
|
||||
kernel=false
|
||||
windows=false
|
||||
full=true
|
||||
packages=
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
if [ "$EVENT_NAME" = push ]; then
|
||||
# The queue lands the very commit its merge_group run tested. If that run passed,
|
||||
# testing the push again proves nothing new; otherwise (a merge that skipped the
|
||||
# queue, or the API cannot answer) test everything.
|
||||
# curl and grep only: Select checks of a push runs on the self-hosted runner.
|
||||
tested=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/$REPO/actions/workflows/ci.yml/runs?event=merge_group&head_sha=$PUSH_SHA&status=success&per_page=1" \
|
||||
| grep -o '"total_count": *[0-9]*' | grep -o '[0-9]*$' || echo 0)
|
||||
if [ "${tested:-0}" -gt 0 ]; then
|
||||
echo "The merge queue already tested $PUSH_SHA: repository checks only."
|
||||
else
|
||||
native=true
|
||||
windows=true
|
||||
fi
|
||||
elif [ "$EVENT_NAME" != pull_request ]; then
|
||||
native=true
|
||||
windows=true
|
||||
else
|
||||
@@ -121,24 +156,40 @@ jobs:
|
||||
# Code changed but nothing was selected: be safe and test everything.
|
||||
if [ "$native" = true ] && [ "$full" != true ] && [ -z "$packages" ]; then full=true; fi
|
||||
fi
|
||||
# The reference tests replay recipes through solidcraft-cli: needed when it, or anything
|
||||
# it depends on, is selected.
|
||||
oracle=$native
|
||||
if [ "$EVENT_NAME" = pull_request ] && [ "$full" != true ]; then
|
||||
case " $packages " in *" solidcraft-cli "*) ;; *) oracle=false ;; esac
|
||||
fi
|
||||
# The pull request fast lane: Windows and the reference tests run on main (and the nightly),
|
||||
# not on every pull request push; a break there is fixed forward on main.
|
||||
if [ "$EVENT_NAME" = pull_request ]; then windows=false; oracle=false; fi
|
||||
{
|
||||
echo "native=$native"
|
||||
echo "kernel=$kernel"
|
||||
echo "windows=$windows"
|
||||
echo "oracle=$oracle"
|
||||
echo "full=$full"
|
||||
echo "packages=$packages"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
checks:
|
||||
name: Format and repository checks
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh checks
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: repo-checks
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -152,6 +203,10 @@ jobs:
|
||||
run: cargo xtask licences --check
|
||||
- name: Dependency layers
|
||||
run: cargo xtask layers
|
||||
- name: Sorted lists
|
||||
run: cargo xtask lists
|
||||
- name: Design format pending changes
|
||||
run: bash scripts/test-format-pending.sh
|
||||
- name: Web app offline use and updates (headless Chrome)
|
||||
run: cargo xtask web-offline-test
|
||||
- name: Affected-package selection self-test
|
||||
@@ -163,18 +218,19 @@ jobs:
|
||||
name: Native Linux Clippy
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted'
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh clippy
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
# The self-hosted runner keeps its build directory and Cargo home instead.
|
||||
- if: runner.environment == 'github-hosted'
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-clippy
|
||||
@@ -193,8 +249,9 @@ jobs:
|
||||
name: Build tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && '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) && 90 || 45 }}
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
@@ -236,12 +293,13 @@ jobs:
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: [changes, build-tests]
|
||||
runs-on: ${{ ((github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && matrix.shard <= 2) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
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) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
# Pull requests test their affected crates in 2 shards; main and the nightly use 4.
|
||||
shard: ${{ fromJSON(github.event_name == 'pull_request' && '[1, 2]' || '[1, 2, 3, 4]') }}
|
||||
env:
|
||||
# Pictures (snapshots, screenshots, ui.render, the viewport's offscreen tests) are drawn on
|
||||
# the GPU. The runner has none: Mesa's lavapipe (software Vulkan) draws them, and a test that
|
||||
@@ -266,6 +324,7 @@ jobs:
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
|
||||
run: |
|
||||
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
|
||||
all=()
|
||||
@@ -277,17 +336,7 @@ jobs:
|
||||
# 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/4" --no-tests=pass "${all[@]}"
|
||||
- name: Robustness goldens sample (pull requests that touch the kernel)
|
||||
# Every 8th seed of tests/robustness/goldens.json (each seed is a test), a quarter per
|
||||
# shard; pushes to main and the nightly run every seed in the step above.
|
||||
if: github.event_name == 'pull_request' && needs.changes.outputs.kernel == 'true'
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --ignore-default-filter --partition "slice:$SHARD/4" --no-tests=pass \
|
||||
-E 'package(solidcraft-engine) & test(/^tests::robustness_goldens::seed_(00|08|16|24|32|40|48|56|64|72|80|88)$/)'
|
||||
--profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${all[@]}"
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -392,7 +441,7 @@ jobs:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -402,11 +451,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted'
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh doc-tests
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# The self-hosted runner keeps its build directory and Cargo home instead.
|
||||
- if: runner.environment == 'github-hosted'
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: doc-tests
|
||||
@@ -416,13 +466,33 @@ jobs:
|
||||
with:
|
||||
version: v0.16.0
|
||||
# nextest does not run doc tests.
|
||||
- run: cargo test --workspace --profile ci --locked --doc
|
||||
- name: Doc tests
|
||||
env:
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
PACKAGES: ${{ needs.changes.outputs.packages }}
|
||||
run: |
|
||||
# Pull requests test the affected packages that have a library (only libraries have doc
|
||||
# tests); everything else, the whole workspace.
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then
|
||||
scope=()
|
||||
libs=$(cargo metadata --no-deps --format-version 1 --locked \
|
||||
| python3 -I -c 'import json, sys; print(" ".join(p["name"] for p in json.load(sys.stdin)["packages"] if any("lib" in t["kind"] for t in p["targets"])))')
|
||||
for p in $PACKAGES; do
|
||||
case " $libs " in *" $p "*) scope+=(-p "$p") ;; esac
|
||||
done
|
||||
fi
|
||||
if [ ${#scope[@]} -eq 0 ]; then
|
||||
echo "No affected package has a library: no doc tests to run."
|
||||
exit 0
|
||||
fi
|
||||
cargo test "${scope[@]}" --profile ci --locked --doc
|
||||
|
||||
oracle:
|
||||
name: Reference tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
if: needs.changes.outputs.oracle == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -431,8 +501,14 @@ jobs:
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh oracle
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: oracle
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -443,7 +519,7 @@ jobs:
|
||||
- name: Build the command line
|
||||
run: cargo build -p solidcraft-cli --profile ci --locked
|
||||
- name: Replay tests/oracle and compare with expected.json
|
||||
run: cargo xtask oracle --cli target/ci/solidcraft-cli
|
||||
run: cargo xtask oracle --cli "${CARGO_TARGET_DIR:-target}/ci/solidcraft-cli"
|
||||
- name: Upload the report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -512,7 +588,7 @@ jobs:
|
||||
ci:
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
@@ -520,6 +596,7 @@ jobs:
|
||||
CHECKS: ${{ needs.checks.result }}
|
||||
NATIVE: ${{ needs.changes.outputs.native }}
|
||||
WINDOWS: ${{ needs.changes.outputs.windows }}
|
||||
ORACLE_RUN: ${{ needs.changes.outputs.oracle }}
|
||||
CLIPPY: ${{ needs.clippy.result }}
|
||||
BUILD: ${{ needs.build-tests.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
@@ -535,6 +612,8 @@ jobs:
|
||||
test "$BUILD" = success
|
||||
test "$TESTS" = success
|
||||
test "$DOCS" = success
|
||||
fi
|
||||
if [ "$ORACLE_RUN" = true ]; then
|
||||
test "$ORACLE" = success
|
||||
fi
|
||||
if [ "$WINDOWS" = true ]; then
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# The web app as a container image on GitHub's container registry, for self-hosting
|
||||
# (packaging/docker; the book's "Self-hosting with Docker"):
|
||||
#
|
||||
# ghcr.io/<owner>/solidcraft-web:latest the latest published release
|
||||
# ghcr.io/<owner>/solidcraft-web:0.14.0 a release (also 0.14); pre-releases get only their version
|
||||
# ghcr.io/<owner>/solidcraft-web:edge main
|
||||
#
|
||||
# For linux/amd64 and linux/arm64. The site is built here, as Pages and Release build it, and
|
||||
# passed to the Dockerfile as its `site` stage. Pull requests that touch the image build and
|
||||
# smoke-test it without publishing. Releases published by an agent or maintainer trigger this;
|
||||
# the nightly pre-releases (made with GITHUB_TOKEN) do not.
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
pull_request:
|
||||
paths: ["packaging/docker/**", ".github/workflows/docker.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
TRUNK_VERSION: v0.21.14
|
||||
|
||||
jobs:
|
||||
image:
|
||||
if: ${{ !github.event.repository.private }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: docker-web
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Install trunk
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Build the web app
|
||||
working-directory: apps/solidcraft-web
|
||||
run: trunk build --release
|
||||
- name: Image name
|
||||
id: image
|
||||
# Registry names are lower case; a fork's owner may not be.
|
||||
run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/solidcraft-web" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
id: meta
|
||||
with:
|
||||
images: ${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=edge,branch=main
|
||||
type=ref,event=pr
|
||||
labels: |
|
||||
org.opencontainers.image.title=SolidCraft (web)
|
||||
org.opencontainers.image.description=SolidCraft, open-source parametric CAD, in the browser: the web app served by nginx on port 8080
|
||||
org.opencontainers.image.licenses=MIT OR Apache-2.0
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- name: Build for the smoke test
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: packaging/docker/Dockerfile
|
||||
build-contexts: site=dist/web
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
tags: solidcraft-web:test
|
||||
- name: Smoke test
|
||||
run: bash packaging/docker/smoke-test.sh solidcraft-web:test
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and publish
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: packaging/docker/Dockerfile
|
||||
build-contexts: site=dist/web
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
@@ -1,9 +1,10 @@
|
||||
# Files many pull requests add to are kept so that additions don't conflict: one file per change
|
||||
# or one sorted line per item (CONTRIBUTING.md, "Lists many changes add to"). Fast, no build.
|
||||
# Pull requests get the same checks in CI's "Format and repository checks" job (one runner slot
|
||||
# per PR instead of three); this copy keeps main honest.
|
||||
name: Lists
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
branches: [main]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/**"]
|
||||
pull_request:
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/**"]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/platforms/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -132,9 +132,13 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. Don't claim by commenting or by editing files.
|
||||
issue wins**; if yours lost, close it. A comment or file edit alone does not replace the draft PR.
|
||||
- After opening the draft PR, comment `/claim` on the issue so the bot records the claimant and
|
||||
adds the `in progress` label. **Do both:** the draft PR establishes ownership and `/claim` marks
|
||||
the issue as taken. Reference `#N` in the PR so the bot can find it.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
|
||||
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
|
||||
and wait while it is over 24; build with `nice`.
|
||||
|
||||
|
||||
+9
-1
@@ -32,6 +32,13 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.mp4 | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/model-floating.png | SolidCraft contributors | original UX mockup (Model tab, floating layout) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/model-compact.png | SolidCraft contributors | original UX mockup (Model tab, compact docked layout) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/sketch.png | SolidCraft contributors | original UX mockup (Sketch tab) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/sheet-metal.png | SolidCraft contributors | original UX mockup (Sheet Metal contextual tab) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/start-page.png | SolidCraft contributors | original UX mockup (start page (File view)) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/workspaces.png | SolidCraft contributors | original UX mockup (workspace switcher) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/command-map.png | SolidCraft contributors | original UX mockup (ribbon command map) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| assets/branding/web/source/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sample-plate.png | SolidCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/bracket.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
@@ -86,6 +93,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/gl.json | Pablo López (pablopeza) | Galician translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| 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/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
@@ -455,7 +463,7 @@ copied into SolidCraft.
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
|
||||
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters (vendor/README.md) |
|
||||
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters; surface second derivatives (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
## Vendored renderer compatibility fix
|
||||
|
||||
@@ -27,8 +27,10 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
|
||||
## Never crash
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
|
||||
(which denies it instead), have no safe API: two functions in
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
|
||||
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
|
||||
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
|
||||
and calls through its function pointers), two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
|
||||
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers) and the Windows-only
|
||||
|
||||
+13
-8
@@ -10,7 +10,7 @@ Help is welcome, whether that's a one-line fix or owning a whole area. This page
|
||||
build it, how the code is laid out, the few rules that aren't negotiable, and where help is
|
||||
needed most.
|
||||
|
||||
Questions, ideas or want to say what you're working on? Join the [Discord](https://solidcraft.io/discord).
|
||||
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
@@ -110,9 +110,10 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
`cargo xtask assets` enforces this.
|
||||
4. **Never crash.** People trust a CAD app with their work.
|
||||
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
|
||||
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events)
|
||||
and `apps/solidcraft/src/native_crash.rs` (Windows native crash capture), and the compiler
|
||||
denies it.
|
||||
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events),
|
||||
`apps/solidcraft/src/native_crash.rs` (Windows native crash capture) and
|
||||
`crates/threedx-client/src/macos.rs` (the 3Dconnexion driver's client API on macOS), and the
|
||||
compiler denies it.
|
||||
- An unsupported case returns an error ("not supported yet: …"); it never panics.
|
||||
- Every call into truck goes through `solidcraft_kernel::guard`.
|
||||
- Every crash fix comes with a regression test.
|
||||
@@ -257,6 +258,9 @@ and a fresh CI run:
|
||||
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
|
||||
- **The lowest-numbered open PR for an issue wins.** If yours lost, close it (or offer to help
|
||||
on the other one).
|
||||
- Then comment `/claim` on the issue so the bot records the claimant and adds the `in progress`
|
||||
label. **Do both:** the draft PR establishes ownership and `/claim` marks the issue as taken.
|
||||
Reference `#N` in the PR so the bot can find it; a comment alone does not replace the draft PR.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim.
|
||||
- Mark the PR ready for review when it is done.
|
||||
@@ -266,10 +270,11 @@ and a fresh CI run:
|
||||
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).
|
||||
|
||||
Comment `/claim` on an issue to take it: you are assigned, it gets the `in progress` label, and
|
||||
the bot replies with the deadline. Open a draft PR referencing the issue within 72 hours, or the
|
||||
claim lapses and the issue is freed again; a claim with a PR is freed after 14 days without
|
||||
pushes. `/unclaim` releases it early (only you or a maintainer can). Issues a maintainer has
|
||||
The `/claim` bot assigns you, adds the `in progress` label, and replies with its automatic cleanup
|
||||
deadline. It frees a claim with no linked PR after 72 hours, or a claim with a PR after 14 days
|
||||
without pushes. These automatic cleanup deadlines are separate from the daily progress and
|
||||
72-hour takeover policy above; follow the draft PR then `/claim` sequence before starting work.
|
||||
`/unclaim` releases the bot claim early (only you or a maintainer can). Issues a maintainer has
|
||||
taken are never freed automatically.
|
||||
|
||||
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
|
||||
|
||||
Generated
+5
@@ -4069,6 +4069,7 @@ dependencies = [
|
||||
"ron",
|
||||
"rustls",
|
||||
"serde_json",
|
||||
"solidcraft-3dx-client",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-space-mouse",
|
||||
"solidcraft-ui-egui",
|
||||
@@ -4077,6 +4078,10 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.15.0"
|
||||
|
||||
@@ -26,6 +26,7 @@ solidcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
solidcraft-space-mouse = { path = "crates/space-mouse" }
|
||||
solidcraft-mcp = { path = "crates/mcp" }
|
||||
solidcraft-derive = { path = "crates/derive" }
|
||||
solidcraft-3dx-client = { path = "crates/threedx-client" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<a href="https://solidcraft.io/download">Download</a> ·
|
||||
<a href="https://solidcraft.io/app">Try it in the browser</a> ·
|
||||
<a href="https://docs.solidcraft.io/">Documentation</a> ·
|
||||
<a href="https://solidcraft.io/discord">Discord</a> ·
|
||||
<a href="https://discord.gg/FtU4NQNVDH">Discord</a> ·
|
||||
<a href="https://github.com/bherbruck/solidcore">SolidCore</a>
|
||||
</p>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<a href="https://github.com/tangency-labs/solidcraft/releases/latest"><img src="https://img.shields.io/github/v/release/tangency-labs/solidcraft?label=release" alt="Latest release" /></a>
|
||||
<a href="https://github.com/tangency-labs/solidcraft/releases"><img src="https://img.shields.io/github/downloads/tangency-labs/solidcraft/total?label=downloads" alt="Downloads" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/tangency-labs/solidcraft" alt="Licence" /></a>
|
||||
<a href="https://solidcraft.io/discord"><img src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://discord.gg/FtU4NQNVDH"><img src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://remade.wiki/projects/solidcraft/"><img src="https://remade.wiki/badges/listed-on-remade-classic.svg" alt="Listed on Remade" /></a>
|
||||
<a href="https://github.com/tangency-labs/solidcraft/stargazers"><img src="https://img.shields.io/github/stars/tangency-labs/solidcraft?style=flat" alt="Stars" /></a>
|
||||
</p>
|
||||
@@ -63,6 +63,10 @@
|
||||
The downloads aren't signed yet. On Windows, if SmartScreen warns you, choose **More info**, then
|
||||
**Run anyway**. On macOS, the first time, right-click SolidCraft in Applications and choose **Open**.
|
||||
|
||||
**On SolidCraft 0.11 to 0.14?** Its **Update now** can't install 0.15.0 or later, because the
|
||||
releases moved to a new address. Download the new version once from the links above; after that,
|
||||
updating from inside SolidCraft works again ([details](https://docs.solidcraft.io/guide/updates.html#updating-from-011-to-014)).
|
||||
|
||||
Older versions, checksums and other formats are on the [releases page](https://github.com/tangency-labs/solidcraft/releases).
|
||||
Install details, upgrading from earlier builds and building from source are in
|
||||
[Getting started](https://docs.solidcraft.io/guide/getting-started.html).
|
||||
@@ -90,7 +94,7 @@ Install details, upgrading from earlier builds and building from source are in
|
||||
**Documentation:** the [SolidCraft book](https://docs.solidcraft.io/) (user guide,
|
||||
developer guide and command reference; sources in [docs/book](docs/book)).
|
||||
|
||||
**Community:** chat with users and contributors on [Discord](https://solidcraft.io/discord).
|
||||
**Community:** chat with users and contributors on [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
**Status: early but usable for simple parts.** 75 of 76 public reference parts
|
||||
match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box
|
||||
@@ -215,7 +219,7 @@ unaccepted artwork Open; run `cargo xtask icons` to check the collection.
|
||||
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
On Windows and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis
|
||||
On Windows, macOS and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis
|
||||
controller interface: a 3Dconnexion SpaceNavigator or SpaceMouse, or a homemade device that
|
||||
declares itself one. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
|
||||
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
|
||||
@@ -253,6 +257,13 @@ lays them out: translation and rotation in separate reports (3Dconnexion's wired
|
||||
in one (its wireless ones and most homemade devices), after the buttons, or narrower than 16 bits.
|
||||
Each axis is scaled from the range the descriptor declares.
|
||||
|
||||
On macOS SolidCraft talks to the 3Dconnexion driver (3DxWare) when it is installed, through the
|
||||
driver's client API (`crates/threedx-client`; the framework is loaded at run time, never bundled).
|
||||
The driver keeps the device to itself, and sends it only to a program it can identify: SolidCraft
|
||||
as an `.app` (the packaged app). Without the driver, or with its helper not running, SolidCraft reads the device through IOKit like on Windows.
|
||||
|
||||
SolidCraft does not follow 3DxWare's profile on macOS. Its default is 3DxWare's object mode.
|
||||
|
||||
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
|
||||
@@ -278,8 +289,8 @@ A script is a JSON list of commands, the same ones the UI runs:
|
||||
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
|
||||
started with `solidcraft --control PORT`, so you can watch the agent model. Tools:
|
||||
`list_commands`, `execute`, `batch`, `inspect_design`, `measure`, `body_topology`,
|
||||
`set_parameter`, `screenshot` (returns the image), `export`, `undo`, `redo`, `new_design`, `open`,
|
||||
`save`. Tool calls reach files only in the open design's folder (when it lies inside your home
|
||||
`set_parameter`, `screenshot` (returns the image), `export`, `crash_reports`, `undo`, `redo`,
|
||||
`new_design`, `open`, `save`. Tool calls reach files only in the open design's folder (when it lies inside your home
|
||||
folder, never home itself or a hidden folder), SolidCraft's recovery folder and folders given with
|
||||
`--allow-path DIR`. Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Details, client
|
||||
configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
+25
-1
@@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | 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 boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane 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 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
@@ -73,6 +73,11 @@ hierarchies are shared; bounded desktop preparation and occlusion use up to eigh
|
||||
Curves are approximated; drawing sheets,
|
||||
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
|
||||
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
|
||||
packages. The first geometry increment defines validated section records in a plane-local
|
||||
frame; the ledger separates that contract from section producers and the Drawing workspace.
|
||||
|
||||
Desktop view DXF export runs in the file-operation worker with five-stage progress and
|
||||
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
|
||||
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
|
||||
@@ -274,6 +279,25 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## 0.2.1 critical fixes
|
||||
|
||||
- Separated multi-profile Join/Cut extrusions into one solid target batch their tools using
|
||||
conservative construction bounds, avoiding a Boolean rebuild for every profile. Live
|
||||
extrusion previews build only their tools; confirmation validates the final Boolean.
|
||||
- Coplanar selected body faces share one linked sketch and one extrusion. Automatic Join/Cut
|
||||
keeps the evaluator's participant scope, including parts of an empty imported component. Desktop OK/Enter
|
||||
computes on a scratch session in the background, with atomic acceptance and cancellation.
|
||||
Planar section stitching projects lines/arcs analytically and removes duplicate endpoints.
|
||||
- Imported rotated face groups can check all full start-face seams in one guarded
|
||||
construction. Separated tools may batch a Join when each meets the same single body;
|
||||
nearby uninvolved bodies retain the original contact decisions. Regression comparisons
|
||||
cover all four extrusion operations.
|
||||
- Inward multi-face cuts retain every eligible target and subtract separated tools together.
|
||||
Safe interior classification handles roundoff at shared imported line/arc endpoints.
|
||||
Straight extrusion feedback caches existing face/profile triangles and updates the display
|
||||
directly, including a newly opened STEP's single identity component placement; other
|
||||
extents, moved or repeated components and curved sketch profiles keep the Engine preview.
|
||||
- The extrusion fixes retain tracked Boolean history and the current `solidcraft` packages.
|
||||
Confirmation and tool previews use main's shared background calculation. Main's collapsed
|
||||
blend-wall refusal and its tests are retained, including smaller valid radii and mirrored parts.
|
||||
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
|
||||
the command palette open, at normal and enlarged display scales. Fit still accounts for
|
||||
dialog coverage; covered controls do not receive clicks through dialogs.
|
||||
|
||||
@@ -423,6 +423,24 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
"sketch": profile_sketch(f), "profiles": profiles_of(f), "path_sketch": path.get("sketch"), "path": path.get("curves"),
|
||||
"operation": op(f), "name": name, "body_names": bodies}}));
|
||||
}
|
||||
// A boundary surface: a loop of one sketch's curves (`sketch`, `curves`), or a network
|
||||
// (`u`, `v`), as `surface.boundary` takes them.
|
||||
Some("boundary_surface") => {
|
||||
let mut p = json!({"name": name});
|
||||
for k in ["sketch", "curves", "continuity", "u", "v", "operation"] {
|
||||
if let Some(v) = f.get(k) {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(b) = bodies.as_array().and_then(|a| a.first()) {
|
||||
p["body_name"] = b.clone();
|
||||
}
|
||||
out.push(json!({"command": "surface.boundary", "params": p}));
|
||||
}
|
||||
Some("thicken") => out.push(json!({"command": "surface.thicken", "params": {
|
||||
"bodies": f.get("bodies"), "thickness": f.get("thickness"), "symmetric": f.get("symmetric").cloned().unwrap_or(json!(false)),
|
||||
"operation": op(f), "name": name}})),
|
||||
Some("remove") => out.push(json!({"command": "solid.remove", "params": {"bodies": f.get("bodies"), "name": name}})),
|
||||
Some("shell") => {
|
||||
let faces: Vec<Value> = f.get("faces_removed").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
|
||||
out.push(json!({"command": "solid.shell", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
// unchanged, the kept build starts; when a new build is deployed, it is downloaded first
|
||||
// (waiting at most UPDATE_TIMEOUT_MS; the download goes on in the background after that) and
|
||||
// started. When the site has no `build.json` (the app was removed), launches go to the network,
|
||||
// the kept build answering when that fails. Each page then gets its files from the build it
|
||||
// started (remembered in PAGES), never from another build with a file of the same name.
|
||||
// the kept build answering when that fails. So they do when `build.json` is redirected or
|
||||
// refused (a sign-in in front of the app): the browser then shows the sign-in. Each page then
|
||||
// gets its files from the build it started (remembered in PAGES), never from another build
|
||||
// with a file of the same name.
|
||||
//
|
||||
// A cache is only deleted when nothing uses it: a download holds its lock (Web Locks, shared,
|
||||
// in any tab or worker; the browser releases it when the download ends or its worker stops), and
|
||||
@@ -158,9 +160,14 @@ function within(ms, promise) {
|
||||
/** No app is deployed here any more (the server says `build.json` doesn't exist). */
|
||||
const GONE = "gone";
|
||||
|
||||
/** The deployed build's version and files, GONE, or null (unknown: offline, failing, invalid). */
|
||||
/** The site wants a sign-in first (`build.json` is redirected, often to another origin, or refused). */
|
||||
const SIGN_IN = "sign-in";
|
||||
|
||||
/** The deployed build's version and files, GONE, SIGN_IN, or null (unknown: offline, failing, invalid). */
|
||||
async function deployed() {
|
||||
const response = await fetch(url("build.json"), { cache: "no-cache" });
|
||||
// `manual`: a redirect to another origin would only fail (CORS), looking like being offline.
|
||||
const response = await fetch(url("build.json"), { cache: "no-cache", redirect: "manual" });
|
||||
if (response.type === "opaqueredirect" || response.status === 401 || response.status === 403) return SIGN_IN;
|
||||
if (response.status === 404 || response.status === 410) return GONE;
|
||||
const build = response.ok ? await response.json() : null;
|
||||
const valid = build && typeof build.version === "string" && Array.isArray(build.files);
|
||||
@@ -182,6 +189,7 @@ async function launch(event) {
|
||||
if (!kept) return [await fetch(event.request), NETWORK]; // Nothing kept yet (the first visit).
|
||||
extend(event, sweep());
|
||||
const build = await within(NAVIGATE_TIMEOUT_MS, deployed());
|
||||
if (build === SIGN_IN) return [await fetch(event.request), NETWORK];
|
||||
if (build === GONE) {
|
||||
const page = await fetch(event.request).catch(() => null);
|
||||
return page && page.ok ? [page, NETWORK] : [kept, CACHE];
|
||||
|
||||
@@ -41,7 +41,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
|
||||
# Keychain on macOS, Credential Manager on Windows, the Secret Service (pure-Rust D-Bus) elsewhere.
|
||||
keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] }
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))'.dependencies]
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
@@ -65,6 +65,11 @@ windows-sys = { version = "0.61", features = [
|
||||
] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# The 3D mouse through the 3Dconnexion driver, which keeps the device to itself (src/space_mouse.rs).
|
||||
solidcraft-3dx-client = { workspace = true }
|
||||
# The 3D mouse (src/space_mouse.rs), in the shared-device open mode so that a running 3DxWare
|
||||
# driver does not shut the app out.
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["macos-shared-device"] }
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
|
||||
|
||||
@@ -25,7 +25,7 @@ mod native_crash;
|
||||
mod native_frame;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
mod space_mouse;
|
||||
mod startup;
|
||||
|
||||
@@ -38,7 +38,7 @@ struct App {
|
||||
starting: Option<std::path::PathBuf>,
|
||||
/// Frames begun; the second one means the first was drawn (startup is over).
|
||||
frames: u32,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ impl eframe::App for App {
|
||||
self.solve.open_path_async(&f);
|
||||
}
|
||||
self.solve.logic(ctx);
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
if !self.solve.file_picker_busy() {
|
||||
self.space_mouse.tick(&mut self.solve, ctx);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ impl eframe::App for App {
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// Before the app's own hook, which adds the control channel's events.
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw);
|
||||
self.solve.raw_input_hook(raw);
|
||||
}
|
||||
@@ -655,14 +655,14 @@ fn main() -> eframe::Result {
|
||||
if !notes.is_empty() {
|
||||
app.set_status(notes.join(" "), true);
|
||||
}
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
solidcraft_engine::crash::phase("first frame");
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
starting,
|
||||
frames: 0,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
space_mouse,
|
||||
}))
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 3D mouse navigation on Windows (through hidapi) and Linux (through spacenavd when it runs, else
|
||||
//! 3D mouse navigation on Windows and macOS (through hidapi) and Linux (through spacenavd when it runs, else
|
||||
//! hidraw). Read directly, any HID multi-axis controller works: 3Dconnexion's and homemade ones.
|
||||
//! The device reader never touches the document or UI; it publishes the latest axes, and the egui
|
||||
//! thread applies them once per frame.
|
||||
@@ -8,11 +8,12 @@ mod hidraw;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod spacenavd;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
@@ -21,6 +22,14 @@ use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame};
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
/// Whether the 3Dconnexion driver (macOS) has the device: it then reports the cap, and reading
|
||||
/// the HID device would fail or report it twice.
|
||||
static DRIVER_CONNECTED: AtomicBool = AtomicBool::new(false);
|
||||
/// Whether this app registered with the 3Dconnexion driver (macOS). The driver then has the device
|
||||
/// whether or not it has said so yet, so a failure to open it by HID is no news.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
static DRIVER_REGISTERED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(2);
|
||||
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
|
||||
/// Room for one input report: the 4096 bytes `solidcraft_space_mouse` places fields in, plus the
|
||||
@@ -188,7 +197,7 @@ impl Reading {
|
||||
|
||||
/// The Generic Desktop "Multi-Axis Controller" usage names a 3D mouse's motion interface, whoever
|
||||
/// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn is_3d_mouse(info: &DeviceInfo) -> bool {
|
||||
info.usage_page() == 0x01 && info.usage() == 0x08
|
||||
}
|
||||
@@ -210,6 +219,9 @@ fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Inst
|
||||
|
||||
/// Publishes one input report from the device to the egui thread.
|
||||
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
|
||||
if DRIVER_CONNECTED.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(report, layout, &mut reading, Instant::now())
|
||||
{
|
||||
@@ -234,7 +246,7 @@ fn reset(ctx: &Context, shared: &Mutex<Reading>) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
let Ok(mut api) = HidApi::new() else {
|
||||
log::warn!("3D mouse: HID initialization failed");
|
||||
@@ -244,7 +256,16 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
log::warn!("3D mouse: device scan failed: {error}");
|
||||
} else if let Some((device, layout)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| {
|
||||
let device = info.open_device(&api).ok()?;
|
||||
let device = info
|
||||
.open_device(&api)
|
||||
.inspect_err(|error| {
|
||||
// On macOS: missing Input Monitoring permission, or the 3DxWare helper holding the device.
|
||||
// The driver having the device is the reason it cannot be opened, not a problem.
|
||||
if !DRIVER_CONNECTED.load(Ordering::Relaxed) && !DRIVER_REGISTERED.load(Ordering::Relaxed) {
|
||||
log::warn!("3D mouse: cannot open the device: {error}");
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
let layout = device_layout(&device, info.vendor_id())?;
|
||||
Some((device, layout))
|
||||
}) {
|
||||
@@ -270,7 +291,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
/// read, or it's empty), a 3Dconnexion device (Logitech's id for the old SpaceNavigator,
|
||||
/// 3Dconnexion's own for the rest) gets 3Dconnexion's layout, and any other device is left alone
|
||||
/// rather than guessed at.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn device_layout(device: &hidapi::HidDevice, vendor_id: u16) -> Option<Layout> {
|
||||
let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
|
||||
let read = device.get_report_descriptor(&mut descriptor);
|
||||
@@ -335,6 +356,34 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS: the 3Dconnexion driver gives the device to its own clients only, so SolidCraft registers
|
||||
/// as one. Without the driver (or with its helper not running) the HID reader above takes over.
|
||||
#[cfg(target_os = "macos")]
|
||||
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();
|
||||
}
|
||||
}
|
||||
Event::Buttons(_) => {}
|
||||
});
|
||||
match started {
|
||||
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
|
||||
Err(error) => log::info!("3D mouse: no driver client: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Reading>>,
|
||||
last_frame: Instant,
|
||||
@@ -354,6 +403,8 @@ impl SpaceMouse {
|
||||
if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
log::warn!("3D mouse: reader could not start: {error}");
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
start_driver_client(ctx, &shared);
|
||||
Self {
|
||||
shared,
|
||||
last_frame: now,
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"Density": "Hustota",
|
||||
"Designs you open or save appear here.": "Zde se zobrazí návrhy, které otevřete nebo uložíte.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Určeno volbou K objektu. Pro úpravu úhlového rozsahu zvolte Úhel.",
|
||||
"Direction": "Směr",
|
||||
"Discard": "Zahodit",
|
||||
"Do not export hidden edges": "Neexportovat skryté hrany",
|
||||
"Double-click to rename": "Dvojklikem přejmenujete",
|
||||
@@ -121,6 +122,7 @@
|
||||
"Home view": "Výchozí pohled",
|
||||
"In This Design": "V tomto návrhu",
|
||||
"In place (surfaces)": "Na místě (plochy)",
|
||||
"In plane": "V rovině",
|
||||
"Inside": "Uvnitř",
|
||||
"Interferes": "Koliduje",
|
||||
"Issue title": "Název hlášení",
|
||||
@@ -154,6 +156,7 @@
|
||||
"No matching appearances": "Žádné odpovídající vzhledy",
|
||||
"No visible bodies to export.": "Žádná viditelná tělesa k exportu.",
|
||||
"None": "Žádné",
|
||||
"Normal to sketch": "Kolmo ke skice",
|
||||
"Not available yet": "Zatím není k dispozici",
|
||||
"Notch": "Zářez",
|
||||
"Opacity": "Neprůhlednost",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"Current orthographic view (1:1 mm)": "Aktuelle orthogonale Ansicht (1:1 mm)",
|
||||
"Current perspective view": "Aktuelle perspektivische Ansicht",
|
||||
"Curvature (G2)": "Krümmung (G2)",
|
||||
"Direction": "Richtung",
|
||||
"Do not export hidden edges": "Verdeckte Kanten nicht exportieren",
|
||||
"Don't ask again": "Nicht mehr fragen",
|
||||
"Drive Joint": "Gelenk antreiben",
|
||||
@@ -47,6 +48,7 @@
|
||||
"Holes": "Löcher",
|
||||
"Holes (edges on them)": "Löcher (ihre Kanten)",
|
||||
"In place (surfaces)": "An Ort und Stelle (Flächen)",
|
||||
"In plane": "In der Ebene",
|
||||
"Inside": "Innen",
|
||||
"Length": "Länge",
|
||||
"Max": "Max",
|
||||
@@ -55,6 +57,7 @@
|
||||
"New study": "Neue Untersuchung",
|
||||
"No visible bodies to export.": "Keine sichtbaren Körper zum Exportieren.",
|
||||
"None": "Keine",
|
||||
"Normal to sketch": "Normal zur Skizze",
|
||||
"Notch": "Ausklinkung",
|
||||
"Outside": "Außen",
|
||||
"Overall length": "Gesamtlänge",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"Arc length": "Arc length",
|
||||
"Area": "Area",
|
||||
"Attachment": "Attachment",
|
||||
"BOUNDARY SURFACE": "BOUNDARY SURFACE",
|
||||
"BROWSER": "BROWSER",
|
||||
"Bend Line Position": "Bend Line Position",
|
||||
"Bend Position": "Bend Position",
|
||||
@@ -73,6 +74,7 @@
|
||||
"Counterbore depth": "Counterbore depth",
|
||||
"Countersink angle": "Countersink angle",
|
||||
"Create Issue — review draft": "Create Issue — review draft",
|
||||
"Cross curves": "Cross curves",
|
||||
"Cube": "Cube",
|
||||
"Current orthographic view (1:1 mm)": "Current orthographic view (1:1 mm)",
|
||||
"Current perspective view": "Current perspective view",
|
||||
@@ -86,6 +88,7 @@
|
||||
"Density": "Density",
|
||||
"Designs you open or save appear here.": "Designs you open or save appear here.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Determined by To Object. Choose Angle to edit the angular extent.",
|
||||
"Direction": "Direction",
|
||||
"Discard": "Discard",
|
||||
"Do not export hidden edges": "Do not export hidden edges",
|
||||
"Don't ask again": "Don't ask again",
|
||||
@@ -128,6 +131,7 @@
|
||||
"Home view": "Home view",
|
||||
"In This Design": "In This Design",
|
||||
"In place (surfaces)": "In place (surfaces)",
|
||||
"In plane": "In plane",
|
||||
"Inside": "Inside",
|
||||
"Interferes": "Interferes",
|
||||
"Issue title": "Issue title",
|
||||
@@ -163,6 +167,7 @@
|
||||
"No matching appearances": "No matching appearances",
|
||||
"No visible bodies to export.": "No visible bodies to export.",
|
||||
"None": "None",
|
||||
"Normal to sketch": "Normal to sketch",
|
||||
"Not available yet": "Not available yet",
|
||||
"Notch": "Notch",
|
||||
"Opacity": "Opacity",
|
||||
@@ -555,6 +560,8 @@
|
||||
"cmd.sketch_isoparametric_curve": "Isoparametric Curve",
|
||||
"cmd.sketch_line3d": "3D Line",
|
||||
"cmd.sketch_line_midpoint": "Midpoint Line",
|
||||
"cmd.sketch_loose_ends": "Loose Ends",
|
||||
"cmd.sketch_loose_ends_tie": "Tie Loose Ends",
|
||||
"cmd.sketch_move": "Move/Copy Sketch Entities",
|
||||
"cmd.sketch_move3d": "Move 3D Point",
|
||||
"cmd.sketch_move_point": "Drag Sketch Point",
|
||||
@@ -794,6 +801,8 @@
|
||||
"sketch.degrees_of_freedom": "{count} degrees of freedom",
|
||||
"sketch.draw_curves": "Draw as {linetype}",
|
||||
"sketch.make_selection": "Convert the selection to {linetype}",
|
||||
"sketch.open_ends": "Open {count}",
|
||||
"sketch.untied_ends": "Untied {count}",
|
||||
"sketch_text.align_center": "Center",
|
||||
"sketch_text.align_left": "Left",
|
||||
"sketch_text.align_right": "Right",
|
||||
@@ -1298,6 +1307,7 @@
|
||||
"ui.one_side": "One Side",
|
||||
"ui.open": "Open",
|
||||
"ui.open_ellipsis": "Open...",
|
||||
"ui.open_ends_tip": "Touches nothing",
|
||||
"ui.open_preferences": "Open Preferences",
|
||||
"ui.open_recent": "Open Recent",
|
||||
"ui.operation": "Operation",
|
||||
@@ -1359,6 +1369,8 @@
|
||||
"ui.reset_the_settings_on_this_page": "Reset the settings on this page",
|
||||
"ui.restore": "Restore",
|
||||
"ui.restore_defaults": "Restore Defaults",
|
||||
"ui.reverse_horizontal_pan_direction": "Reverse horizontal pan direction",
|
||||
"ui.reverse_horizontal_pan_tip": "Horizontal two-finger scrolling and the thumb wheel pan the other way",
|
||||
"ui.reverse_zoom_direction": "Reverse zoom direction",
|
||||
"ui.revolve": "Revolve",
|
||||
"ui.right": "Right",
|
||||
@@ -1440,6 +1452,8 @@
|
||||
"ui.theme": "Theme",
|
||||
"ui.thickness": "Thickness",
|
||||
"ui.thread": "Thread",
|
||||
"ui.tie_ends": "Tie",
|
||||
"ui.tie_ends_tip": "Constrain all untied ends",
|
||||
"ui.timeline_bottom": "Bottom timeline",
|
||||
"ui.timeline_left_note": "The left layout shows the rollback bar above the parts and scene browser.",
|
||||
"ui.timeline_left_sidebar": "Left sidebar",
|
||||
@@ -1462,6 +1476,7 @@
|
||||
"ui.units_of_new_designs": "Units of new designs",
|
||||
"ui.unlock": "Unlock",
|
||||
"ui.unsuppress": "Unsuppress",
|
||||
"ui.untied_ends_tip": "Touches by position only",
|
||||
"ui.untitled": "Untitled",
|
||||
"ui.up_axis_note": "Which axis points up in the views (axis view, Home, Look At). Designs are unchanged.",
|
||||
"ui.updateToCurrentView": "Update to Current View",
|
||||
|
||||
+77
-14
@@ -7,6 +7,7 @@
|
||||
"A new configuration, a copy of the active one": "Una configuración nueva, copia de la activa",
|
||||
"AI": "AI",
|
||||
"About SolidCraft": "Acerca de SolidCraft",
|
||||
"Acceleration": "Aceleración",
|
||||
"Active": "Activo",
|
||||
"Active rule": "Regla activa",
|
||||
"Add": "Añadir",
|
||||
@@ -14,8 +15,10 @@
|
||||
"Add Mid Radius": "Añadir radio intermedio",
|
||||
"Add Row": "Añadir fila",
|
||||
"Add condition": "Añadir condición",
|
||||
"Against": "Contra",
|
||||
"Alignment": "Alineación",
|
||||
"All Sketch Points": "Todos los puntos del boceto",
|
||||
"All components": "Todos los componentes",
|
||||
"All open designs": "Todos los diseños abiertos",
|
||||
"Allowed expressions, separated by semicolons": "Expresiones permitidas, separadas por punto y coma",
|
||||
"Also deletes": "También elimina",
|
||||
@@ -57,6 +60,7 @@
|
||||
"Configuration rules": "Reglas de configuración",
|
||||
"Construction": "Construcción",
|
||||
"Contact": "Contacto",
|
||||
"Contact sets": "Conjuntos de contacto",
|
||||
"Continuity": "Continuidad",
|
||||
"Copy Error Message": "Copiar mensaje de error",
|
||||
"Copy Warning Message": "Copiar mensaje de advertencia",
|
||||
@@ -82,16 +86,21 @@
|
||||
"Density": "Densidad",
|
||||
"Designs you open or save appear here.": "Aquí aparecen los diseños que abras o guardes.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por «Hasta objeto». Elige «Ángulo» para modificar la extensión angular.",
|
||||
"Direction": "Dirección",
|
||||
"Discard": "Descartar",
|
||||
"Do not export hidden edges": "No exportar aristas ocultas",
|
||||
"Don't ask again": "No volver a preguntar",
|
||||
"Double-click to rename": "Doble clic para cambiar el nombre",
|
||||
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Doble clic con el botón central en la vista: Inicio (vista inicial que encuadra lo visible), Ajustar (encuadrar sin cambiar la orientación) o nada",
|
||||
"Drive Joint": "Accionar unión",
|
||||
"Duration": "Duración",
|
||||
"Edges": "Aristas",
|
||||
"Edit Profile Sketch": "Editar boceto del perfil",
|
||||
"Enable rule": "Activar regla",
|
||||
"Enabled": "Activado",
|
||||
"Explode": "Explosionar",
|
||||
"Explode Text": "Descomponer texto",
|
||||
"Export Animation…": "Exportar animación…",
|
||||
"Export Current View as DXF": "Exportar vista actual como DXF",
|
||||
"Export Current View as DXF…": "Exportar vista actual como DXF…",
|
||||
"Export DXF…": "Exportar DXF…",
|
||||
@@ -120,6 +129,7 @@
|
||||
"Home view": "Vista de inicio",
|
||||
"In This Design": "En este diseño",
|
||||
"In place (surfaces)": "En su posición (superficies)",
|
||||
"In plane": "En el plano",
|
||||
"Inside": "Interior",
|
||||
"Interferes": "Interfiere",
|
||||
"Issue title": "Título de la incidencia",
|
||||
@@ -149,10 +159,13 @@
|
||||
"Move earlier": "Mover antes",
|
||||
"Move later": "Mover después",
|
||||
"Name": "Nombre",
|
||||
"Nearest": "Más cercano",
|
||||
"New study": "Nuevo estudio",
|
||||
"No crash reports.": "No hay informes de fallos.",
|
||||
"No matching appearances": "No hay apariencias coincidentes",
|
||||
"No visible bodies to export.": "No hay cuerpos visibles para exportar.",
|
||||
"None": "Ninguno",
|
||||
"Normal to sketch": "Normal al boceto",
|
||||
"Not available yet": "Todavía no disponible",
|
||||
"Notch": "Muesca",
|
||||
"Opacity": "Opacidad",
|
||||
@@ -167,16 +180,20 @@
|
||||
"Per Edge": "Por arista",
|
||||
"Pick the tool bodies": "Selecciona los cuerpos herramienta",
|
||||
"Picked": "Seleccionado",
|
||||
"Picked point": "Punto seleccionado",
|
||||
"Place at the origin instead": "Colocar en el origen",
|
||||
"Playback": "Reproducción",
|
||||
"Plot": "Graficar",
|
||||
"Position": "Posición",
|
||||
"Position (0 to 1)": "Posición (de 0 a 1)",
|
||||
"Position (G0)": "Posición (G0)",
|
||||
"Position along the edge, 0 at the start end, 1 at the other": "Posición a lo largo de la arista: 0 en el extremo inicial y 1 en el otro",
|
||||
"Preferences › Design turns the offer back on": "Preferencias › Diseño vuelve a activar la oferta",
|
||||
"Preview Row": "Previsualizar fila",
|
||||
"Preview leaves the current design unchanged.": "La vista previa no modifica el diseño actual.",
|
||||
"Profiles/Curves": "Perfiles/curvas",
|
||||
"Projection": "Proyección",
|
||||
"Quantity": "Cantidad",
|
||||
"RECENT": "RECIENTES",
|
||||
"RULED SURFACE": "SUPERFICIE REGLADA",
|
||||
"Radius Fit": "Ajuste de radio",
|
||||
@@ -207,10 +224,11 @@
|
||||
"Revert Position": "Revertir posición",
|
||||
"Review the report and screenshot before opening GitHub.": "Revisa el informe y la captura antes de abrir GitHub.",
|
||||
"Rim Edge": "Arista del borde",
|
||||
"Roll History Marker Here": "Desplazar marcador del historial hasta aquí",
|
||||
"Roll History Marker Here": "Desplazar marcador de la línea de tiempo hasta aquí",
|
||||
"Round": "Redondo",
|
||||
"Rule": "Regla",
|
||||
"Save Rule": "Guardar regla",
|
||||
"Save the study as an animated GIF, seen as the viewport shows it": "Guardar el estudio como GIF animado, tal como se ve en el visor",
|
||||
"Saved preview. Geometry is being checked before editing.": "Vista previa guardada. Se está comprobando la geometría antes de editarla.",
|
||||
"Scale factor": "Factor de escala",
|
||||
"Search": "Buscar",
|
||||
@@ -241,6 +259,7 @@
|
||||
"Stand this part up?": "¿Poner esta pieza en vertical?",
|
||||
"Step": "Paso",
|
||||
"Steps": "Pasos",
|
||||
"Study": "Estudio",
|
||||
"Style": "Estilo",
|
||||
"Suppressed": "Suprimido",
|
||||
"Swap Ends": "Intercambiar extremos",
|
||||
@@ -260,6 +279,7 @@
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara no permite una vinculación persistente. Elige otra cara o utiliza expresamente un plano fijo.",
|
||||
"To Offset": "Hasta desfase",
|
||||
"Tolerance": "Tolerancia",
|
||||
"Too close": "Demasiado cerca",
|
||||
"Turn off section analysis before exporting the view.": "Desactiva el análisis de sección antes de exportar la vista.",
|
||||
"Type": "Tipo",
|
||||
"UNTRIM": "ANULAR RECORTE",
|
||||
@@ -270,6 +290,7 @@
|
||||
"Use selected face as fixed plane": "Usar la cara seleccionada como plano fijo",
|
||||
"VIEW": "VISTA",
|
||||
"Value": "Valor",
|
||||
"Velocity": "Velocidad",
|
||||
"Vertices": "Vértices",
|
||||
"View navigator": "Navegador de vistas",
|
||||
"Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Solo cuerpos visibles. Las curvas utilizan segmentos rectos (tolerancia de 0,01 mm).",
|
||||
@@ -293,6 +314,13 @@
|
||||
"browser.precision": "Precisión: {precision}",
|
||||
"browser.sketch_name": "Boceto{number}",
|
||||
"browser.units": "Unidades: {units}",
|
||||
"cam.flat": "Cara plana",
|
||||
"cam.follower": "Seguidor",
|
||||
"cam.knife": "Arista de cuchilla",
|
||||
"cam.needs_joints": "necesita una unión que gire y otra que deslice o bascule",
|
||||
"cam.roller": "Rodillo",
|
||||
"cam.roller_pick": "Rodillo (opcional)",
|
||||
"cam.tip_pick": "Punta (opcional)",
|
||||
"cmd.appearance_assign": "Apariencia",
|
||||
"cmd.appearance_library": "Biblioteca de apariencias",
|
||||
"cmd.appearance_list": "Listar apariencias",
|
||||
@@ -385,6 +413,7 @@
|
||||
"cmd.file_slicers": "Laminadores instalados",
|
||||
"cmd.inspect_accessibility": "Análisis de accesibilidad",
|
||||
"cmd.inspect_center_of_mass": "Centro de masas",
|
||||
"cmd.inspect_clearance": "Holgura",
|
||||
"cmd.inspect_compare": "Comparar cuerpos",
|
||||
"cmd.inspect_continuity": "Análisis de continuidad",
|
||||
"cmd.inspect_curvature_comb": "Análisis de peine de curvatura",
|
||||
@@ -394,8 +423,10 @@
|
||||
"cmd.inspect_interference": "Interferencia",
|
||||
"cmd.inspect_isocurve": "Análisis de isocurvas",
|
||||
"cmd.inspect_minimum_radius": "Análisis de radio mínimo",
|
||||
"cmd.inspect_silhouette": "Silueta",
|
||||
"cmd.inspect_zebra": "Análisis de franjas cebra",
|
||||
"cmd.joint_as_built": "Unión según posición actual",
|
||||
"cmd.joint_cam": "Leva",
|
||||
"cmd.joint_create": "Unión",
|
||||
"cmd.joint_delete": "Eliminar unión",
|
||||
"cmd.joint_drive": "Accionar uniones",
|
||||
@@ -426,8 +457,11 @@
|
||||
"cmd.model_threads": "Listar roscas",
|
||||
"cmd.motion_delete": "Eliminar estudio de movimiento",
|
||||
"cmd.motion_export": "Exportar estudio de movimiento",
|
||||
"cmd.motion_export_animation": "Exportar animación del movimiento",
|
||||
"cmd.motion_interference": "Interferencia de movimiento",
|
||||
"cmd.motion_list": "Listar estudios de movimiento",
|
||||
"cmd.motion_play": "Reproducir estudio de movimiento",
|
||||
"cmd.motion_plot": "Graficar estudio de movimiento",
|
||||
"cmd.motion_study": "Estudio de movimiento",
|
||||
"cmd.motion_trace": "Trayectoria de movimiento",
|
||||
"cmd.motion_trace_keep": "Conservar trayectoria de movimiento",
|
||||
@@ -453,7 +487,7 @@
|
||||
"cmd.parts_library": "Piezas normalizadas",
|
||||
"cmd.parts_resize": "Cambiar tamaño de pieza",
|
||||
"cmd.plastic_assign_rule": "Asignar regla de plástico",
|
||||
"cmd.plastic_boss": "Resalte",
|
||||
"cmd.plastic_boss": "Saliente",
|
||||
"cmd.plastic_lip": "Labio",
|
||||
"cmd.plastic_manage_rules": "Gestionar reglas de plástico",
|
||||
"cmd.plastic_rest": "Apoyo",
|
||||
@@ -545,7 +579,7 @@
|
||||
"cmd.sketch_slot_center_point": "Ranura por punto central",
|
||||
"cmd.sketch_slot_center_to_center": "Ranura entre centros",
|
||||
"cmd.sketch_slot_overall": "Ranura por longitud total",
|
||||
"cmd.sketch_snap": "Detección de referencias",
|
||||
"cmd.sketch_snap": "Referencia a objetos",
|
||||
"cmd.sketch_solve": "Resolver boceto",
|
||||
"cmd.sketch_spline3d": "Spline 3D",
|
||||
"cmd.sketch_spline_control_point": "Spline por puntos de control",
|
||||
@@ -570,7 +604,7 @@
|
||||
"cmd.surface_extrude": "Extruir superficie",
|
||||
"cmd.surface_fair": "Suavizar superficie",
|
||||
"cmd.surface_fillet": "Redondeo de superficie",
|
||||
"cmd.surface_loft": "Transición de superficies",
|
||||
"cmd.surface_loft": "Solevación de superficies",
|
||||
"cmd.surface_match": "Igualar superficie",
|
||||
"cmd.surface_offset": "Desfasar superficie",
|
||||
"cmd.surface_patch": "Parche",
|
||||
@@ -592,7 +626,7 @@
|
||||
"cmd.timeline_redefine": "Editar operación",
|
||||
"cmd.timeline_rename": "Cambiar nombre",
|
||||
"cmd.timeline_reorder": "Reordenar operación",
|
||||
"cmd.timeline_roll_to": "Desplazar marcador del historial",
|
||||
"cmd.timeline_roll_to": "Desplazar marcador de la línea de tiempo",
|
||||
"cmd.timeline_rollback": "Mover marcador de la línea de tiempo",
|
||||
"cmd.timeline_suppress": "Suprimir operación",
|
||||
"cmd.view_apply": "Restaurar vista guardada",
|
||||
@@ -631,6 +665,18 @@
|
||||
"drive.stopped_by_contact": "Detenido en {value}: {a} atravesaría {b}",
|
||||
"drive.stopped_by_loop": "Detenido en {value}: el bucle que pasa por {joint} no puede cerrarse más",
|
||||
"drive.unconnected": "No mueve nada: {part} no está conectado a ningún elemento fijado",
|
||||
"dxf.cancel_wait": "Esperando a que se detenga la exportación…",
|
||||
"dxf.cancelled": "Exportación DXF cancelada",
|
||||
"dxf.cancelling": "Cancelando la exportación DXF",
|
||||
"dxf.exporting": "Exportando DXF",
|
||||
"dxf.invalid_tolerance": "La tolerancia de curva debe estar entre 0,0001 y 1 mm.",
|
||||
"dxf.progress.index": "Preparando superficies (2/5)",
|
||||
"dxf.progress.meshes": "Preparando mallas (1/5)",
|
||||
"dxf.progress.occlusion": "Resolviendo líneas ocultas (4/5)",
|
||||
"dxf.progress.projection": "Proyectando geometría (3/5)",
|
||||
"dxf.progress.write": "Escribiendo DXF (5/5)",
|
||||
"dxf.tolerance": "Tolerancia de curva",
|
||||
"dxf.tolerance_help": "Solo cuerpos visibles. Las curvas usan segmentos rectos. Una tolerancia menor da curvas más finas y consume más tiempo y memoria.",
|
||||
"expression, e.g. 25 mm": "expresión, p. ej., 25 mm",
|
||||
"fits the face, e.g. M8": "se ajusta a la cara, p. ej., M8",
|
||||
"from the rule": "de la regla",
|
||||
@@ -662,6 +708,10 @@
|
||||
"joint.from_bodies_hint": "Cada cuerpo seleccionado pasa a un componente nuevo propio y conserva su posición.",
|
||||
"joint.one_body": "Las dos selecciones pertenecen a {body}. Selecciona la segunda en otra pieza.",
|
||||
"joint.one_component": "Estas piezas son cuerpos de un mismo componente: {bodies}. Una unión mueve un componente respecto a otro, por lo que cada pieza debe tener su propio componente.",
|
||||
"motion.checked_to": "Comprobado solo hasta el paso {step}: la comprobación agotó su presupuesto de trabajo",
|
||||
"motion.collision": "{a} · {b}: pasos {steps}",
|
||||
"motion.duration_hint": "segundos (vacío: por paso)",
|
||||
"motion.no_collisions": "Sin colisiones durante el estudio",
|
||||
"nav.touchpad.help": "Mantén pulsado Mayús y mueve el puntero para encuadrar; mantén Alt/Opción y muévelo para orbitar. No hace falta pulsar ningún botón del ratón. El desplazamiento vertical hace zoom.",
|
||||
"nav.touchpad.label": "Panel táctil",
|
||||
"nav.touchpad.orbit": "Orbitar (mantén Alt/Opción y mueve el puntero; sin pulsar botones)",
|
||||
@@ -724,17 +774,17 @@
|
||||
"render.use_preview": "Usar vista previa integrada",
|
||||
"sample.bracket.about": "Brida base con pestañas laterales y reborde, según una regla de 2 mm",
|
||||
"sample.bracket.name": "Soporte de chapa",
|
||||
"sample.enclosure.about": "Caja vaciada con labio, alojamientos para tornillos, apoyo y cierre a presión",
|
||||
"sample.enclosure.about": "Caja vaciada con labio, salientes para tornillos, apoyo y encaje a presión",
|
||||
"sample.enclosure.name": "Carcasa de plástico",
|
||||
"sample.flange.about": "Cubo y brida con patrón circular controlado por un parámetro",
|
||||
"sample.flange.name": "Brida atornillada",
|
||||
"sample.hinge.about": "Dos componentes y una unión de revolución accionada hasta 60°",
|
||||
"sample.hinge.name": "Conjunto de bisagra",
|
||||
"sample.plate.about": "Placa paramétrica con redondeos, agujeros y un resalte perforado",
|
||||
"sample.plate.about": "Placa paramétrica con redondeos, agujeros y un saliente perforado",
|
||||
"sample.plate.name": "Placa de ejemplo",
|
||||
"sample.vents.about": "Ranuras en un patrón rectangular: las filas, las columnas y el paso son parámetros",
|
||||
"sample.vents.name": "Placa de ventilación",
|
||||
"settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia la vista, nunca el diseño ni las exportaciones.",
|
||||
"settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia el visor, nunca el diseño ni las exportaciones.",
|
||||
"settings.frame_rate_note": "Muestra los fotogramas por segundo en la esquina inferior derecha de la vista. Mientras esté activado, la vista se redibuja continuamente.",
|
||||
"settings.measure_axis_distances": "Mostrar distancias X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Mostrar las cotas de cada eje global junto a la distancia total mientras está abierta la herramienta Medir.",
|
||||
@@ -760,6 +810,10 @@
|
||||
"sketch_text.path_hint": "Curva del boceto que seguirá el texto: haz clic aquí y después en la curva",
|
||||
"sketch_text.use_font_file": "Usar el archivo de fuente {path}",
|
||||
"the parts library is not available": "la biblioteca de piezas no está disponible",
|
||||
"thread.automatic_size": "Tamaño automático",
|
||||
"thread.designation": "Designación",
|
||||
"thread.iso_metric": "Métrica ISO",
|
||||
"thread.select_size": "Seleccionar tamaño",
|
||||
"timeline.group": "Nueva carpeta de operaciones",
|
||||
"timeline.move_many": "Mover elementos de la línea de tiempo",
|
||||
"timeline.move_to_group": "Mover operaciones a la carpeta",
|
||||
@@ -1005,6 +1059,9 @@
|
||||
"ui.construct": "Construir",
|
||||
"ui.construction": "Construcción",
|
||||
"ui.context_menu": "Menú contextual",
|
||||
"ui.continuity_g0": "G0 arista",
|
||||
"ui.continuity_g1": "G1 tangencia",
|
||||
"ui.continuity_g2": "G2 curvatura",
|
||||
"ui.copy": "Copiar",
|
||||
"ui.corner": "Esquina",
|
||||
"ui.corners": "Esquinas",
|
||||
@@ -1066,7 +1123,7 @@
|
||||
"ui.equivalent_radius": "Radio equivalente",
|
||||
"ui.expand": "Expandir",
|
||||
"ui.exportDxf": "Exportar DXF…",
|
||||
"ui.export_ellipsis": "Exportar...",
|
||||
"ui.export_ellipsis": "Exportar…",
|
||||
"ui.export_ellipsis_keymap": "Exportar…",
|
||||
"ui.export_keymap_tip": "Exportar las teclas, los favoritos de la búsqueda S y la barra de herramientas como archivo",
|
||||
"ui.extend": "Extender",
|
||||
@@ -1092,6 +1149,8 @@
|
||||
"ui.fully_constrained": "Totalmente restringido",
|
||||
"ui.general": "General",
|
||||
"ui.graphics": "Gráficos",
|
||||
"ui.grid_follows_view": "La cuadrícula sigue a la vista",
|
||||
"ui.grid_follows_view_hint": "Las vistas laterales muestran la cuadrícula en el plano vertical que tienen enfrente, en lugar del suelo",
|
||||
"ui.ground_shadow": "Sombra en el suelo",
|
||||
"ui.groupSelected": "Agrupar selección",
|
||||
"ui.heading_add_ins": "COMPLEMENTOS",
|
||||
@@ -1118,7 +1177,7 @@
|
||||
"ui.heading_home": "INICIO",
|
||||
"ui.heading_insert": "INSERTAR",
|
||||
"ui.heading_inspect": "INSPECCIONAR",
|
||||
"ui.heading_loft": "TRANSICIÓN",
|
||||
"ui.heading_loft": "SOLEVACIÓN",
|
||||
"ui.heading_make": "FABRICAR",
|
||||
"ui.heading_measure": "MEDIR",
|
||||
"ui.heading_mesh": "MALLA",
|
||||
@@ -1188,7 +1247,7 @@
|
||||
"ui.line": "Línea",
|
||||
"ui.linetype": "Tipo de línea",
|
||||
"ui.lock": "Bloquear",
|
||||
"ui.loft": "Transición",
|
||||
"ui.loft": "Solevación",
|
||||
"ui.look_at": "Mirar hacia",
|
||||
"ui.look_at_anim_desc": "Vista de eje, Mirar hacia, Inicio y Ajustar",
|
||||
"ui.manufacture": "Fabricación",
|
||||
@@ -1241,7 +1300,7 @@
|
||||
"ui.offset_plane": "Plano desfasado",
|
||||
"ui.one_side": "Un lado",
|
||||
"ui.open": "Abrir",
|
||||
"ui.open_ellipsis": "Abrir...",
|
||||
"ui.open_ellipsis": "Abrir…",
|
||||
"ui.open_preferences": "Abrir preferencias",
|
||||
"ui.open_recent": "Abrir reciente",
|
||||
"ui.operation": "Operación",
|
||||
@@ -1286,6 +1345,8 @@
|
||||
"ui.radial_context_menu": "Menú contextual radial",
|
||||
"ui.radius": "Radio",
|
||||
"ui.radius_type": "Tipo de radio",
|
||||
"ui.rail": "Línea guía",
|
||||
"ui.rail_fit": "Ajuste a la guía",
|
||||
"ui.reachable": "Alcanzable",
|
||||
"ui.rectangle": "Rectángulo",
|
||||
"ui.rectangular_pattern": "Patrón rectangular",
|
||||
@@ -1306,12 +1367,13 @@
|
||||
"ui.right": "Derecha",
|
||||
"ui.save": "Guardar",
|
||||
"ui.save_as": "Guardar como",
|
||||
"ui.save_as_ellipsis": "Guardar como...",
|
||||
"ui.save_as_ellipsis": "Guardar como…",
|
||||
"ui.save_mesh_no_slicer": "No se ha encontrado ningún laminador: elige Personalizado… y selecciona tu laminador, o desactiva Enviar al laminador",
|
||||
"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.scale": "Escala",
|
||||
"ui.second_rail": "Segunda línea guía",
|
||||
"ui.section_analysis": "Análisis de sección",
|
||||
"ui.select": "Seleccionar",
|
||||
"ui.selectOther": "Seleccionar otro…",
|
||||
@@ -1351,7 +1413,7 @@
|
||||
"ui.smooth": "Suave",
|
||||
"ui.smooth_wheel_zoom": "Zoom suave con la rueda",
|
||||
"ui.smooth_wheel_zoom_tip": "Desactívalo para aplicar cada movimiento de la rueda inmediatamente, sin suavizado posterior",
|
||||
"ui.snap": "Ajustar a referencias",
|
||||
"ui.snap": "Referencia a objetos",
|
||||
"ui.space_mouse_hint": "Desplaza y gira la vista a la vez; los bocetos solo permiten desplazamiento y zoom.",
|
||||
"ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse",
|
||||
"ui.spacing": "Espaciado",
|
||||
@@ -1362,6 +1424,7 @@
|
||||
"ui.stand_up_tip": "Tras abrir un archivo STEP o IGES con Z hacia arriba, ofrecer un cuarto de vuelta alrededor de X",
|
||||
"ui.start": "Iniciar",
|
||||
"ui.start_radius": "Radio inicial",
|
||||
"ui.stretch": "Estirar",
|
||||
"ui.supersampling": "Supermuestreo (SSAA)",
|
||||
"ui.supersampling_adaptive": "Solo cuando la vista está inmóvil",
|
||||
"ui.supersampling_off": "Desactivado (1×)",
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"Density": "Masse volumique",
|
||||
"Designs you open or save appear here.": "Les conceptions que vous ouvrez ou enregistrez apparaissent ici.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Déterminé par Jusqu'à l'objet. Choisir Angle pour modifier l'étendue angulaire.",
|
||||
"Direction": "Direction",
|
||||
"Discard": "Abandonner",
|
||||
"Do not export hidden edges": "Ne pas exporter les arêtes cachées",
|
||||
"Don't ask again": "Ne plus demander",
|
||||
@@ -98,6 +99,7 @@
|
||||
"Enable rule": "Activer la règle",
|
||||
"Enabled": "Activé",
|
||||
"Explode": "Éclater",
|
||||
"Explode Text": "Décomposer le texte",
|
||||
"Export Animation…": "Exporter l'animation…",
|
||||
"Export Current View as DXF": "Exporter la vue actuelle en DXF",
|
||||
"Export Current View as DXF…": "Exporter la vue actuelle en DXF…",
|
||||
@@ -127,6 +129,7 @@
|
||||
"Home view": "Vue d'accueil",
|
||||
"In This Design": "Dans cette conception",
|
||||
"In place (surfaces)": "En place (surfaces)",
|
||||
"In plane": "Dans le plan",
|
||||
"Inside": "Intérieur",
|
||||
"Interferes": "Interfère",
|
||||
"Issue title": "Titre du ticket",
|
||||
@@ -156,11 +159,13 @@
|
||||
"Move earlier": "Déplacer plus tôt",
|
||||
"Move later": "Déplacer plus tard",
|
||||
"Name": "Nom",
|
||||
"Nearest": "Corps le plus proche",
|
||||
"New study": "Nouvelle étude",
|
||||
"No crash reports.": "Aucun rapport de plantage.",
|
||||
"No matching appearances": "Aucune apparence correspondante",
|
||||
"No visible bodies to export.": "Aucun corps visible à exporter.",
|
||||
"None": "Aucun",
|
||||
"Normal to sketch": "Normale à l'esquisse",
|
||||
"Not available yet": "Pas encore disponible",
|
||||
"Notch": "Encoche",
|
||||
"Opacity": "Opacité",
|
||||
@@ -274,6 +279,7 @@
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Cette face ne peut pas fournir de rattachement durable. Choisissez une autre face ou utilisez explicitement un plan fixe.",
|
||||
"To Offset": "Jusqu'au décalage",
|
||||
"Tolerance": "Tolérance",
|
||||
"Too close": "Trop proches",
|
||||
"Turn off section analysis before exporting the view.": "Désactivez l'analyse de coupe avant d'exporter la vue.",
|
||||
"Type": "Type",
|
||||
"UNTRIM": "ANNULER L'AJUSTEMENT",
|
||||
@@ -407,6 +413,7 @@
|
||||
"cmd.file_slicers": "Slicers installés",
|
||||
"cmd.inspect_accessibility": "Analyse d'accessibilité",
|
||||
"cmd.inspect_center_of_mass": "Centre de gravité",
|
||||
"cmd.inspect_clearance": "Jeu",
|
||||
"cmd.inspect_compare": "Comparer les corps",
|
||||
"cmd.inspect_continuity": "Analyse de continuité",
|
||||
"cmd.inspect_curvature_comb": "Analyse par peigne de courbure",
|
||||
@@ -416,6 +423,7 @@
|
||||
"cmd.inspect_interference": "Interférence",
|
||||
"cmd.inspect_isocurve": "Analyse des isocourbes",
|
||||
"cmd.inspect_minimum_radius": "Analyse du rayon minimal",
|
||||
"cmd.inspect_silhouette": "Silhouette",
|
||||
"cmd.inspect_zebra": "Analyse zébrée",
|
||||
"cmd.joint_as_built": "Liaison telle que construite",
|
||||
"cmd.joint_cam": "Came",
|
||||
@@ -1051,6 +1059,9 @@
|
||||
"ui.construct": "Construire",
|
||||
"ui.construction": "Construction",
|
||||
"ui.context_menu": "Menu contextuel",
|
||||
"ui.continuity_g0": "G0 angle vif",
|
||||
"ui.continuity_g1": "G1 tangence",
|
||||
"ui.continuity_g2": "G2 courbure",
|
||||
"ui.copy": "Copier",
|
||||
"ui.corner": "Angle",
|
||||
"ui.corners": "Angles",
|
||||
@@ -1138,6 +1149,8 @@
|
||||
"ui.fully_constrained": "Entièrement contrainte",
|
||||
"ui.general": "Général",
|
||||
"ui.graphics": "Graphisme",
|
||||
"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",
|
||||
"ui.groupSelected": "Grouper la sélection",
|
||||
"ui.heading_add_ins": "COMPLÉMENTS",
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"Density": "Densidade",
|
||||
"Designs you open or save appear here.": "Os deseños que abras ou gardes aparecen aquí.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por Ata o obxecto. Escolle Ángulo para editar a extensión angular.",
|
||||
"Direction": "Dirección",
|
||||
"Discard": "Descartar",
|
||||
"Do not export hidden edges": "Non exportar as arestas ocultas",
|
||||
"Double-click to rename": "Dobre clic para renomear",
|
||||
@@ -121,6 +122,7 @@
|
||||
"Home view": "Vista de inicio",
|
||||
"In This Design": "Neste deseño",
|
||||
"In place (surfaces)": "No sitio (superficies)",
|
||||
"In plane": "No plano",
|
||||
"Inside": "Dentro",
|
||||
"Interferes": "Interfire",
|
||||
"Issue title": "Título da incidencia",
|
||||
@@ -154,6 +156,7 @@
|
||||
"No matching appearances": "Non hai aparencias coincidentes",
|
||||
"No visible bodies to export.": "Non hai corpos visibles para exportar.",
|
||||
"None": "Ningún",
|
||||
"Normal to sketch": "Normal ao bosquexo",
|
||||
"Not available yet": "Aínda non dispoñible",
|
||||
"Notch": "Entalle",
|
||||
"Opacity": "Opacidade",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,7 @@
|
||||
"Arc length": "円弧の長さ",
|
||||
"Area": "表面積",
|
||||
"Attachment": "アタッチメント",
|
||||
"BOUNDARY SURFACE": "境界サーフェス",
|
||||
"BROWSER": "ブラウザ",
|
||||
"Bend Line Position": "曲げ線位置",
|
||||
"Bend Position": "曲げ位置",
|
||||
@@ -73,6 +74,7 @@
|
||||
"Counterbore depth": "座ぐり深さ",
|
||||
"Countersink angle": "皿穴角度",
|
||||
"Create Issue — review draft": "Issueを作成 — 下書きの確認",
|
||||
"Cross curves": "交差する曲線",
|
||||
"Cube": "キューブ",
|
||||
"Current orthographic view (1:1 mm)": "現在の平行投影(実寸・mm)",
|
||||
"Current perspective view": "現在の透視投影",
|
||||
@@ -86,6 +88,7 @@
|
||||
"Density": "密度",
|
||||
"Designs you open or save appear here.": "開いたデザインや保存したデザインがここに表示されます。",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "「対象オブジェクトまで」により決定されます。角度範囲を編集するには「角度」を選択してください。",
|
||||
"Direction": "方向",
|
||||
"Discard": "破棄",
|
||||
"Do not export hidden edges": "出力しない",
|
||||
"Don't ask again": "今後確認しない",
|
||||
@@ -128,6 +131,7 @@
|
||||
"Home view": "ホームビュー",
|
||||
"In This Design": "このデザイン内",
|
||||
"In place (surfaces)": "その場で(サーフェス)",
|
||||
"In plane": "平面内",
|
||||
"Inside": "内側",
|
||||
"Interferes": "干渉あり",
|
||||
"Issue title": "Issueタイトル",
|
||||
@@ -163,6 +167,7 @@
|
||||
"No matching appearances": "一致する外観がありません",
|
||||
"No visible bodies to export.": "出力する表示中のボディがありません。",
|
||||
"None": "なし",
|
||||
"Normal to sketch": "スケッチに垂直",
|
||||
"Not available yet": "まだ利用できません",
|
||||
"Notch": "ノッチ",
|
||||
"Opacity": "不透明度",
|
||||
@@ -555,6 +560,8 @@
|
||||
"cmd.sketch_isoparametric_curve": "アイソパラメトリック曲線",
|
||||
"cmd.sketch_line3d": "3D 線分",
|
||||
"cmd.sketch_line_midpoint": "中点線分",
|
||||
"cmd.sketch_loose_ends": "端点の診断",
|
||||
"cmd.sketch_loose_ends_tie": "未拘束の接点を拘束",
|
||||
"cmd.sketch_move": "スケッチ要素を移動/コピー",
|
||||
"cmd.sketch_move3d": "3D 点を移動",
|
||||
"cmd.sketch_move_point": "スケッチ点をドラッグ",
|
||||
@@ -794,6 +801,8 @@
|
||||
"sketch.degrees_of_freedom": "自由度 {count}",
|
||||
"sketch.draw_curves": "{linetype}として描く",
|
||||
"sketch.make_selection": "選択を{linetype}にする",
|
||||
"sketch.open_ends": "開放 {count}",
|
||||
"sketch.untied_ends": "未拘束 {count}",
|
||||
"sketch_text.align_center": "中央",
|
||||
"sketch_text.align_left": "左",
|
||||
"sketch_text.align_right": "右",
|
||||
@@ -1146,6 +1155,8 @@
|
||||
"ui.fully_constrained": "完全拘束",
|
||||
"ui.general": "一般",
|
||||
"ui.graphics": "グラフィックス",
|
||||
"ui.grid_follows_view": "グリッドをビューに追従させる",
|
||||
"ui.grid_follows_view_hint": "側面ビューでは、グリッドを地面ではなく、そのビューに向いた垂直平面に表示します",
|
||||
"ui.ground_shadow": "地面の影",
|
||||
"ui.groupSelected": "選択をグループ化",
|
||||
"ui.heading_add_ins": "アドイン",
|
||||
@@ -1296,6 +1307,7 @@
|
||||
"ui.one_side": "片側",
|
||||
"ui.open": "開く",
|
||||
"ui.open_ellipsis": "開く…",
|
||||
"ui.open_ends_tip": "どこにも接していない",
|
||||
"ui.open_preferences": "環境設定を開く",
|
||||
"ui.open_recent": "最近使った項目を開く",
|
||||
"ui.operation": "操作",
|
||||
@@ -1357,6 +1369,8 @@
|
||||
"ui.reset_the_settings_on_this_page": "このページの設定をリセット",
|
||||
"ui.restore": "復元",
|
||||
"ui.restore_defaults": "初期値に戻す",
|
||||
"ui.reverse_horizontal_pan_direction": "水平パンの方向を反転",
|
||||
"ui.reverse_horizontal_pan_tip": "水平方向の2本指スクロールとサムホイールで、反対方向にパンします",
|
||||
"ui.reverse_zoom_direction": "ズーム方向を反転",
|
||||
"ui.revolve": "回転",
|
||||
"ui.right": "右側面",
|
||||
@@ -1438,6 +1452,8 @@
|
||||
"ui.theme": "テーマ",
|
||||
"ui.thickness": "厚さ",
|
||||
"ui.thread": "ねじ",
|
||||
"ui.tie_ends": "拘束",
|
||||
"ui.tie_ends_tip": "未拘束をすべて拘束",
|
||||
"ui.timeline_bottom": "下部タイムライン",
|
||||
"ui.timeline_left_note": "左レイアウトでは、ロールバックバーがパーツとシーンブラウザーの上に表示されます。",
|
||||
"ui.timeline_left_sidebar": "左サイドバー",
|
||||
@@ -1460,6 +1476,7 @@
|
||||
"ui.units_of_new_designs": "新規デザインの単位",
|
||||
"ui.unlock": "ロック解除",
|
||||
"ui.unsuppress": "抑制を解除",
|
||||
"ui.untied_ends_tip": "位置だけで接している",
|
||||
"ui.untitled": "無題",
|
||||
"ui.up_axis_note": "ビュー(軸ビュー、ホーム、正面視)で上向きにする軸を指定します。デザインデータ自体は変更されません。",
|
||||
"ui.updateToCurrentView": "現在のビューに更新",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"Current orthographic view (1:1 mm)": "현재 직교 투영 뷰 (1:1 mm)",
|
||||
"Current perspective view": "현재 원근 투영 뷰",
|
||||
"Curvature (G2)": "곡률 (G2)",
|
||||
"Direction": "방향",
|
||||
"Do not export hidden edges": "숨겨진 모서리 내보내지 않기",
|
||||
"Don't ask again": "다시 묻지 않기",
|
||||
"Drive Joint": "조인트 구동",
|
||||
@@ -47,6 +48,7 @@
|
||||
"Holes": "구멍",
|
||||
"Holes (edges on them)": "구멍 (구멍의 모서리)",
|
||||
"In place (surfaces)": "제자리에서 (서피스)",
|
||||
"In plane": "평면 내",
|
||||
"Inside": "안쪽",
|
||||
"Length": "길이",
|
||||
"Max": "최대",
|
||||
@@ -55,6 +57,7 @@
|
||||
"New study": "새 연구",
|
||||
"No visible bodies to export.": "표시된 바디가 없어 내보낼 수 없습니다.",
|
||||
"None": "없음",
|
||||
"Normal to sketch": "스케치에 수직",
|
||||
"Notch": "노치",
|
||||
"Outside": "바깥쪽",
|
||||
"Overall length": "전체 길이",
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"Density": "Densidade",
|
||||
"Designs you open or save appear here.": "Os projetos que você abrir ou salvar aparecem aqui.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por Até o objeto. Escolha Ângulo para editar a extensão angular.",
|
||||
"Direction": "Direção",
|
||||
"Discard": "Descartar",
|
||||
"Do not export hidden edges": "Não exportar arestas ocultas",
|
||||
"Double-click to rename": "Clique duas vezes para renomear",
|
||||
@@ -121,6 +122,7 @@
|
||||
"Home view": "Vista inicial",
|
||||
"In This Design": "Neste projeto",
|
||||
"In place (surfaces)": "No lugar (superfícies)",
|
||||
"In plane": "No plano",
|
||||
"Inside": "Dentro",
|
||||
"Interferes": "Interfere",
|
||||
"Issue title": "Título da issue",
|
||||
@@ -154,6 +156,7 @@
|
||||
"No matching appearances": "Nenhuma aparência correspondente",
|
||||
"No visible bodies to export.": "Nenhum corpo visível para exportar.",
|
||||
"None": "Nenhum",
|
||||
"Normal to sketch": "Normal ao esboço",
|
||||
"Not available yet": "Ainda não disponível",
|
||||
"Notch": "Entalhe",
|
||||
"Opacity": "Opacidade",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"Density": "Плотность",
|
||||
"Designs you open or save appear here.": "Здесь появляются открытые и сохранённые дизайны.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Определяется параметром «До объекта». Выберите «Угол», чтобы задать угловой предел.",
|
||||
"Direction": "Направление",
|
||||
"Discard": "Отменить",
|
||||
"Double-click to rename": "Двойной щелчок — переименовать",
|
||||
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Двойной щелчок средней кнопкой в виде: «Главная» (главный вид, кадрирует показанное), «Вписать» (кадрирует, сохраняя ориентацию) или ничего",
|
||||
@@ -106,6 +107,7 @@
|
||||
"Height": "Высота",
|
||||
"Home view": "Главный вид",
|
||||
"In This Design": "В этом дизайне",
|
||||
"In plane": "В плоскости",
|
||||
"Interferes": "Пересекается",
|
||||
"Issue title": "Заголовок Issue",
|
||||
"Joint Origin1": "Точка сопряжения1",
|
||||
@@ -134,6 +136,7 @@
|
||||
"New study": "Новое исследование",
|
||||
"No crash reports.": "Нет отчётов о сбоях.",
|
||||
"No matching appearances": "Нет подходящих внешних видов",
|
||||
"Normal to sketch": "По нормали к эскизу",
|
||||
"Not available yet": "Пока недоступно",
|
||||
"Notch": "Вырез",
|
||||
"Opacity": "Непрозрачность",
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"Density": "Yoğunluk",
|
||||
"Designs you open or save appear here.": "Açtığınız veya kaydettiğiniz tasarımlar burada görünür.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Nesneye kadar seçeneğiyle belirlenir. Açısal kapsamı düzenlemek için Açı seçeneğini seçin.",
|
||||
"Direction": "Yön",
|
||||
"Discard": "At",
|
||||
"Double-click to rename": "Yeniden adlandırmak için çift tıklayın",
|
||||
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Görünümde orta tuşla çift tıklama: Başlangıç (gösterileni çerçeveleyen başlangıç görünümü), Sığdır (yönü koruyarak gösterileni çerçeveler) veya hiçbir şey",
|
||||
@@ -114,6 +115,7 @@
|
||||
"Home view": "Başlangıç görünümü",
|
||||
"In This Design": "Bu tasarımda",
|
||||
"In place (surfaces)": "Yerinde (yüzeyler)",
|
||||
"In plane": "Düzlemde",
|
||||
"Inside": "İç",
|
||||
"Interferes": "Girişim var",
|
||||
"Issue title": "Sorun başlığı",
|
||||
@@ -147,6 +149,7 @@
|
||||
"No crash reports.": "Çökme raporu yok.",
|
||||
"No matching appearances": "Eşleşen görünüm yok",
|
||||
"None": "Hiçbiri",
|
||||
"Normal to sketch": "Eskize dik",
|
||||
"Not available yet": "Henüz kullanılamıyor",
|
||||
"Opacity": "Opaklık",
|
||||
"Open GitHub draft": "GitHub taslağını aç",
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"Density": "Густина",
|
||||
"Designs you open or save appear here.": "Проєкти, які ви відкриваєте чи зберігаєте, з'являються тут.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Визначається об'єктом призначення. Виберіть «Кут», щоб редагувати кутову протяжність.",
|
||||
"Direction": "Напрямок",
|
||||
"Discard": "Відхилити",
|
||||
"Do not export hidden edges": "Не експортувати приховані ребра",
|
||||
"Double-click to rename": "Двічі клацніть, щоб перейменувати",
|
||||
@@ -126,6 +127,7 @@
|
||||
"Home view": "Головний вид",
|
||||
"In This Design": "У цьому проєкті",
|
||||
"In place (surfaces)": "На місці (поверхні)",
|
||||
"In plane": "У площині",
|
||||
"Inside": "Усередину",
|
||||
"Interferes": "Перетинається",
|
||||
"Issue title": "Заголовок повідомлення",
|
||||
@@ -160,6 +162,7 @@
|
||||
"No matching appearances": "Немає відповідних виглядів",
|
||||
"No visible bodies to export.": "Немає видимих тіл для експорту.",
|
||||
"None": "Немає",
|
||||
"Normal to sketch": "За нормаллю до ескізу",
|
||||
"Not available yet": "Ще недоступно",
|
||||
"Notch": "Виріз",
|
||||
"Opacity": "Непрозорість",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"Arc length": "弧长",
|
||||
"Area": "面积",
|
||||
"Attachment": "附着",
|
||||
"BOUNDARY SURFACE": "边界曲面",
|
||||
"BROWSER": "浏览器",
|
||||
"Bend Line Position": "弯折线位置",
|
||||
"Bend Position": "折弯位置",
|
||||
@@ -73,6 +74,7 @@
|
||||
"Counterbore depth": "沉孔深度",
|
||||
"Countersink angle": "沉头角度",
|
||||
"Create Issue — review draft": "创建 Issue——审阅草稿",
|
||||
"Cross curves": "交叉曲线",
|
||||
"Cube": "立方体",
|
||||
"Current orthographic view (1:1 mm)": "当前正交视图(1:1毫米)",
|
||||
"Current perspective view": "当前透视视图",
|
||||
@@ -86,6 +88,7 @@
|
||||
"Density": "密度",
|
||||
"Designs you open or save appear here.": "你打开或保存的设计会显示在此处。",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "由“到对象”确定。选择“角度”可编辑角度范围。",
|
||||
"Direction": "方向",
|
||||
"Discard": "放弃",
|
||||
"Do not export hidden edges": "不导出隐藏线",
|
||||
"Don't ask again": "不再询问",
|
||||
@@ -128,6 +131,7 @@
|
||||
"Home view": "主视图",
|
||||
"In This Design": "在此设计中",
|
||||
"In place (surfaces)": "原位(曲面)",
|
||||
"In plane": "在平面内",
|
||||
"Inside": "内侧",
|
||||
"Interferes": "发生干涉",
|
||||
"Issue title": "Issue 标题",
|
||||
@@ -163,6 +167,7 @@
|
||||
"No matching appearances": "没有匹配的外观",
|
||||
"No visible bodies to export.": "没有可导出的可见实体。",
|
||||
"None": "无",
|
||||
"Normal to sketch": "垂直于草图",
|
||||
"Not available yet": "暂不可用",
|
||||
"Notch": "缺口",
|
||||
"Opacity": "不透明度",
|
||||
@@ -555,6 +560,8 @@
|
||||
"cmd.sketch_isoparametric_curve": "等参数曲线",
|
||||
"cmd.sketch_line3d": "三维直线",
|
||||
"cmd.sketch_line_midpoint": "中点线",
|
||||
"cmd.sketch_loose_ends": "松散端点",
|
||||
"cmd.sketch_loose_ends_tie": "约束松散端点",
|
||||
"cmd.sketch_move": "移动/复制草图图元",
|
||||
"cmd.sketch_move3d": "移动三维点",
|
||||
"cmd.sketch_move_point": "拖动草图点",
|
||||
@@ -794,6 +801,8 @@
|
||||
"sketch.degrees_of_freedom": "{count} 个自由度",
|
||||
"sketch.draw_curves": "绘制为 {linetype}",
|
||||
"sketch.make_selection": "将选择转换为 {linetype}",
|
||||
"sketch.open_ends": "开放 {count}",
|
||||
"sketch.untied_ends": "未约束 {count}",
|
||||
"sketch_text.align_center": "居中",
|
||||
"sketch_text.align_left": "左对齐",
|
||||
"sketch_text.align_right": "右对齐",
|
||||
@@ -1146,6 +1155,8 @@
|
||||
"ui.fully_constrained": "完全约束",
|
||||
"ui.general": "常规",
|
||||
"ui.graphics": "图形",
|
||||
"ui.grid_follows_view": "网格跟随视图",
|
||||
"ui.grid_follows_view_hint": "在侧视图中,将网格显示在朝向该视图的竖直平面上,而不是地面上",
|
||||
"ui.ground_shadow": "地面阴影",
|
||||
"ui.groupSelected": "将所选编组",
|
||||
"ui.heading_add_ins": "加载项",
|
||||
@@ -1296,6 +1307,7 @@
|
||||
"ui.one_side": "单侧",
|
||||
"ui.open": "打开",
|
||||
"ui.open_ellipsis": "打开...",
|
||||
"ui.open_ends_tip": "未接触任何曲线",
|
||||
"ui.open_preferences": "打开首选项",
|
||||
"ui.open_recent": "打开最近",
|
||||
"ui.operation": "操作",
|
||||
@@ -1357,6 +1369,8 @@
|
||||
"ui.reset_the_settings_on_this_page": "重置此页面上的设置",
|
||||
"ui.restore": "还原",
|
||||
"ui.restore_defaults": "恢复默认值",
|
||||
"ui.reverse_horizontal_pan_direction": "反转水平平移方向",
|
||||
"ui.reverse_horizontal_pan_tip": "水平双指滚动和拇指滚轮向相反方向平移",
|
||||
"ui.reverse_zoom_direction": "反转缩放方向",
|
||||
"ui.revolve": "旋转",
|
||||
"ui.right": "右视",
|
||||
@@ -1438,6 +1452,8 @@
|
||||
"ui.theme": "主题",
|
||||
"ui.thickness": "厚度",
|
||||
"ui.thread": "螺纹",
|
||||
"ui.tie_ends": "约束",
|
||||
"ui.tie_ends_tip": "约束全部未约束端点",
|
||||
"ui.timeline_bottom": "底部时间轴",
|
||||
"ui.timeline_left_note": "左侧布局下,回退条显示在零件和场景浏览器的上方。",
|
||||
"ui.timeline_left_sidebar": "左侧边栏",
|
||||
@@ -1460,6 +1476,7 @@
|
||||
"ui.units_of_new_designs": "新设计的单位",
|
||||
"ui.unlock": "解锁",
|
||||
"ui.unsuppress": "取消抑制",
|
||||
"ui.untied_ends_tip": "仅位置重合",
|
||||
"ui.untitled": "无标题",
|
||||
"ui.up_axis_note": "视图中朝上的轴(轴视图、主视图、注视)。设计本身不变。",
|
||||
"ui.updateToCurrentView": "更新为当前视图",
|
||||
|
||||
@@ -864,7 +864,15 @@ mod tests {
|
||||
|
||||
fn solved(mut sketch: solidcraft_sketch::Sketch) -> SolvedSketch {
|
||||
let report = solidcraft_sketch::solve(&mut sketch);
|
||||
SolvedSketch { feature: 1, name: "sources".into(), plane: solidcraft_geom::Plane::XY, sketch, report, profiles: Vec::new() }
|
||||
SolvedSketch {
|
||||
feature: 1,
|
||||
name: "sources".into(),
|
||||
plane: solidcraft_geom::Plane::XY,
|
||||
sketch,
|
||||
report,
|
||||
profiles: Vec::new(),
|
||||
ends: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! The existing length display policy, shared by windowed and headless consumers.
|
||||
//!
|
||||
//! Values are stored in millimetres and displayed in the five design units. Unknown unit
|
||||
//! names retain the millimetre fallback; expression units have a separate, broader vocabulary.
|
||||
//! These functions format values, not validate them: non-finite inputs retain Rust's `NaN`,
|
||||
//! `inf` and `-inf` text. Callers that require finite measurements must validate them first.
|
||||
|
||||
/// Units per millimetre and the unit's name. Unknown names use millimetres.
|
||||
pub fn unit_scale(units: &str) -> (f64, &'static str) {
|
||||
match units {
|
||||
"cm" => (0.1, "cm"),
|
||||
"m" => (0.001, "m"),
|
||||
"in" => (1.0 / 25.4, "in"),
|
||||
"ft" => (1.0 / 304.8, "ft"),
|
||||
_ => (1.0, "mm"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inch labels retain a meaningful fourth digit without promoting floating-point noise.
|
||||
/// `value` is already converted to the requested display unit, not a millimetre value.
|
||||
pub fn length_precision(value: f64, units: &str, decimals: u8) -> usize {
|
||||
let base = usize::from(decimals.min(8));
|
||||
if units != "in" {
|
||||
return base;
|
||||
}
|
||||
let needs_four = ((value * 10_000.0).round() - (value * 1000.0).round() * 10.0).abs() >= 0.5;
|
||||
base.max(if needs_four { 4 } else { 3 })
|
||||
}
|
||||
|
||||
/// A millimetre length in the requested display unit, without a unit suffix.
|
||||
pub fn length_number(mm: f64, units: &str, decimals: u8) -> String {
|
||||
let value = mm * unit_scale(units).0;
|
||||
format!("{:.*}", length_precision(value, units, decimals), value)
|
||||
}
|
||||
|
||||
/// A length in millimetres in `units`, with `decimals` (for display).
|
||||
pub fn format_length(mm: f64, units: &str, decimals: u8) -> String {
|
||||
let (_, u) = unit_scale(units);
|
||||
format!("{} {u}", length_number(mm, units, decimals))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_length, length_number, length_precision, unit_scale};
|
||||
|
||||
#[test]
|
||||
fn all_design_units_keep_bare_and_suffixed_labels() {
|
||||
for (mm, units, bare, suffixed) in [
|
||||
(12.5, "mm", "12.50", "12.50 mm"),
|
||||
(125.0, "cm", "12.50", "12.50 cm"),
|
||||
(12_500.0, "m", "12.50", "12.50 m"),
|
||||
(317.5, "in", "12.500", "12.500 in"),
|
||||
(3810.0, "ft", "12.50", "12.50 ft"),
|
||||
] {
|
||||
assert_eq!(length_number(mm, units, 2), bare, "{units}");
|
||||
assert_eq!(format_length(mm, units, 2), suffixed, "{units}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inch_labels_keep_three_or_four_digits_without_promoting_noise() {
|
||||
for (mm, decimals, expected) in [
|
||||
(25.4, 0, "1.000"),
|
||||
(25.4 * 1.234, 2, "1.234"),
|
||||
(25.4 * 1.2345, 0, "1.2345"),
|
||||
(-25.4 * 1.2345, 3, "-1.2345"),
|
||||
(25.4 * 0.001, 2, "0.001"),
|
||||
(25.4 * (1.0 + 1e-12), 2, "1.000"),
|
||||
(25.4, 4, "1.0000"),
|
||||
(25.4, 5, "1.00000"),
|
||||
(0.0, 0, "0.000"),
|
||||
] {
|
||||
assert_eq!(length_number(mm, "in", decimals), expected, "{mm} mm, {decimals} places");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_uses_the_converted_value_and_caps_requested_places() {
|
||||
assert_eq!(length_precision(1.2345, "in", 2), 4);
|
||||
assert_eq!(length_precision(1.234, "in", 2), 3);
|
||||
assert_eq!(length_precision(1.2345, "mm", 2), 2);
|
||||
assert_eq!(length_precision(1.2345, "in", 6), 6);
|
||||
assert_eq!(length_precision(1.2345, "in", u8::MAX), 8);
|
||||
assert_eq!(length_precision(1.2345, "mm", u8::MAX), 8);
|
||||
assert_eq!(length_number(12.5, "mm", u8::MAX), "12.50000000");
|
||||
assert_eq!(length_number(25.4, "in", u8::MAX), "1.00000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_and_signed_zero_keep_the_existing_display_policy() {
|
||||
for (mm, decimals, expected) in [
|
||||
(0.0, 2, "0.00"),
|
||||
(-0.0, 2, "-0.00"),
|
||||
(-0.0001, 2, "-0.00"),
|
||||
(-12.5, 2, "-12.50"),
|
||||
(12.5, 0, "12"),
|
||||
(13.5, 0, "14"),
|
||||
(-12.5, 0, "-12"),
|
||||
(-13.5, 0, "-14"),
|
||||
(1.25, 1, "1.2"),
|
||||
(1.75, 1, "1.8"),
|
||||
] {
|
||||
assert_eq!(length_number(mm, "mm", decimals), expected, "{mm} mm, {decimals} places");
|
||||
}
|
||||
assert_eq!(format_length(-0.0, "in", 2), "-0.000 in");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_display_units_keep_the_millimetre_fallback() {
|
||||
for units in ["", "inch", " in ", "IN", "unsupported"] {
|
||||
assert_eq!(unit_scale(units), (1.0, "mm"), "{units}");
|
||||
assert_eq!(length_number(25.4, units, 2), "25.40", "{units}");
|
||||
assert_eq!(format_length(25.4, units, 2), "25.40 mm", "{units}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_text_is_preserved_without_claiming_validation() {
|
||||
assert_eq!(length_number(f64::NAN, "mm", 2), "NaN");
|
||||
assert_eq!(format_length(f64::INFINITY, "in", u8::MAX), "inf in");
|
||||
assert_eq!(format_length(f64::NEG_INFINITY, "cm", 2), "-inf cm");
|
||||
}
|
||||
}
|
||||
@@ -901,7 +901,8 @@ pub enum FeatureKind {
|
||||
targets: Vec<BodyName>,
|
||||
},
|
||||
/// A thin wall from open sketch curves: `thickness` across the sketch plane, filling from
|
||||
/// the curves toward the body (to the next face, or `depth`). A web makes one per curve.
|
||||
/// the curves toward the body (to the next face, or `depth`), or with `normal` along the
|
||||
/// sketch normal. A web makes one per curve.
|
||||
Rib {
|
||||
sketch: SketchId,
|
||||
curves: Vec<String>,
|
||||
@@ -917,6 +918,10 @@ pub enum FeatureKind {
|
||||
/// Web: each curve is its own wall.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
web: bool,
|
||||
/// Grow the wall along the sketch normal (thickness across the curves, in the sketch
|
||||
/// plane) instead of across the plane from the curves.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
normal: bool,
|
||||
},
|
||||
/// Move planar faces (at the given points) onto a target plane parallel to them.
|
||||
ReplaceFace {
|
||||
@@ -1460,6 +1465,19 @@ pub enum FeatureKind {
|
||||
#[serde(default = "boundary_samples")]
|
||||
samples: usize,
|
||||
},
|
||||
/// One surface through a curve network (`surface.boundary` with `u` and `v`, issue #763): the
|
||||
/// curves `u` run one way (two or more: the surface lofts through them) and `v` cross them the
|
||||
/// other way (two each way: a Coons patch; more: a Gordon surface through every curve). A side
|
||||
/// may be a point (a three-sided patch). A curve that is a chain of a surface's open edges may
|
||||
/// carry G1 or G2 with the face beside it. `operation` join sews the surface into the body of
|
||||
/// its first edge curve (a solid when it closes the gap there).
|
||||
SurfaceNetwork {
|
||||
u: Vec<NetworkCurve>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
v: Vec<NetworkCurve>,
|
||||
#[serde(default)]
|
||||
operation: Operation,
|
||||
},
|
||||
/// One face of a surface body rebuilt against another body (`surface.match`): the rebuilt
|
||||
/// boundary nearest the chain of `edges` on `target` takes that chain's place and repeats the
|
||||
/// target's own shape along the seam up to `order`, while the boundary opposite it, and the
|
||||
@@ -1687,6 +1705,27 @@ pub struct BlendSide {
|
||||
pub edges: Vec<Position>,
|
||||
}
|
||||
|
||||
/// One curve of a boundary surface's network ([`FeatureKind::SurfaceNetwork`]): a chain of a
|
||||
/// sketch's `curves` (planar or 3D, meeting end to end), a chain of `body`'s edges picked by points
|
||||
/// on them (`edges`), or a side collapsed to the point `point` (the apex of a three-sided patch).
|
||||
/// A chain of a surface's open edges may carry `continuity` with the face beside it (G0, the
|
||||
/// default, G1 or G2).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
pub struct NetworkCurve {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sketch: Option<SketchId>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub curves: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<BodyName>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub edges: Vec<Position>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub point: Option<Position>,
|
||||
#[serde(default = "patch_g0", skip_serializing_if = "is_g0")]
|
||||
pub continuity: Continuity,
|
||||
}
|
||||
|
||||
/// How a patch on a loop of edges meets the faces beside the loop: every edge with `all`, but the
|
||||
/// edges picked in `edges` with their own.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
@@ -1728,9 +1767,12 @@ fn is_g0(g: &Continuity) -> bool {
|
||||
/// The continuity a blend repeats by default: the tangent plane on both sides.
|
||||
/// The stations a boundary patch carries by default.
|
||||
fn boundary_samples() -> usize {
|
||||
25
|
||||
BOUNDARY_SAMPLES
|
||||
}
|
||||
|
||||
/// The stations a boundary patch carries when a design does not say.
|
||||
pub const BOUNDARY_SAMPLES: usize = 25;
|
||||
|
||||
fn blend_orders() -> [Continuity; 2] {
|
||||
[Continuity::G1, Continuity::G1]
|
||||
}
|
||||
@@ -2083,7 +2125,7 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature",
|
||||
FeatureKind::SurfaceLoft { .. } => "SurfaceLoftFeature",
|
||||
FeatureKind::SurfaceBlend { .. } => "BlendFeature",
|
||||
FeatureKind::SurfaceBoundary { .. } => "BoundaryFeature",
|
||||
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "BoundaryFeature",
|
||||
FeatureKind::SurfaceMatch { .. } => "MatchFeature",
|
||||
FeatureKind::SurfaceFair { .. } => "FairFeature",
|
||||
FeatureKind::Stitch { .. } => "StitchFeature",
|
||||
@@ -2163,7 +2205,7 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweep",
|
||||
FeatureKind::SurfaceLoft { .. } => "SurfaceLoft",
|
||||
FeatureKind::SurfaceBlend { .. } => "Blend",
|
||||
FeatureKind::SurfaceBoundary { .. } => "Boundary",
|
||||
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "Boundary",
|
||||
FeatureKind::SurfaceMatch { .. } => "Match",
|
||||
FeatureKind::SurfaceFair { .. } => "Fair",
|
||||
FeatureKind::Stitch { .. } => "Stitch",
|
||||
|
||||
+210
-4
@@ -165,6 +165,8 @@ pub struct SolvedSketch {
|
||||
pub sketch: solidcraft_sketch::Sketch,
|
||||
pub report: SolveReport,
|
||||
pub profiles: Vec<Profile>,
|
||||
/// Open and untied ends of its profile curves (where a profile can fail).
|
||||
pub ends: solidcraft_sketch::EndReport,
|
||||
}
|
||||
|
||||
/// A cosmetic thread: on which cylinder, its size, and the threaded part of the axis.
|
||||
@@ -1033,6 +1035,20 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
|
||||
}
|
||||
return hash.finish();
|
||||
}
|
||||
// A network through 3D sketch curves reads their points through the components the
|
||||
// feature and those sketches are placed in, as a sweep's wired path does.
|
||||
if let FeatureKind::SurfaceNetwork { u, v, .. } = &f.kind {
|
||||
let sketches: Vec<crate::SketchId> = u.iter().chain(v).filter_map(|c| c.sketch).collect();
|
||||
if sketches.iter().any(wired) {
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
doc.component_transform(f.component).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||||
for sketch in &sketches {
|
||||
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
|
||||
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||||
}
|
||||
return hash.finish();
|
||||
}
|
||||
}
|
||||
// To / From Object finds its target where that target's component is placed (#928).
|
||||
let to_object = match &f.kind {
|
||||
FeatureKind::Extrude { extent, .. } => extent.to.is_some() || extent.from.is_some(),
|
||||
@@ -1104,7 +1120,15 @@ impl Model {
|
||||
let Some(last) = self.results.get(i).filter(|r| !r.skipped && r.error.is_none()).and_then(|r| r.state.sketch(feature)) else {
|
||||
return false;
|
||||
};
|
||||
let solved = Arc::new(SolvedSketch { feature, name: last.name.clone(), plane: last.plane, profiles: find_profiles(&sketch), sketch, report });
|
||||
let solved = Arc::new(SolvedSketch {
|
||||
feature,
|
||||
name: last.name.clone(),
|
||||
plane: last.plane,
|
||||
profiles: find_profiles(&sketch),
|
||||
ends: solidcraft_sketch::loose_ends(&sketch),
|
||||
sketch,
|
||||
report,
|
||||
});
|
||||
let shown = self.results.iter().rposition(|r| !r.skipped);
|
||||
for (k, r) in self.results.iter_mut().enumerate().skip(i) {
|
||||
r.stale = true;
|
||||
@@ -1581,6 +1605,29 @@ fn apply_beside_sheet_cut(st: &mut ModelState, f: &Feature, tools: Vec<Traced>,
|
||||
apply_traced(st, f, tools, op, &rest)
|
||||
}
|
||||
|
||||
/// Only one target meets every separated tool. A tool with no contact must retain
|
||||
/// Join's first-target fallback; tools touching different targets must retain absorption order.
|
||||
fn common_join_target(state: &ModelState, indices: &[usize], tools: &[Traced]) -> Result<Option<usize>> {
|
||||
if indices.len() > 64 {
|
||||
return Ok(None);
|
||||
}
|
||||
let bodies: Vec<_> = tools.iter().map(|tool| tool.body.clone()).collect();
|
||||
let mut contacts = vec![None; tools.len()];
|
||||
for &index in indices {
|
||||
let body = state.bodies.get(index).ok_or_else(|| DocError::Invalid("join target disappeared".into()))?;
|
||||
for (contact, meets) in contacts.iter_mut().zip(body.body.meets_each(&bodies)?) {
|
||||
if meets {
|
||||
if contact.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
*contact = Some(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
let common = contacts.first().copied().flatten();
|
||||
Ok(common.filter(|index| contacts.iter().all(|contact| *contact == Some(*index))))
|
||||
}
|
||||
|
||||
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
|
||||
apply_traced(state, f, tools.into_iter().map(Traced::unnamed).collect(), op, targets)
|
||||
}
|
||||
@@ -1636,6 +1683,41 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
}
|
||||
}
|
||||
}
|
||||
// Batching solid targets must preserve Join's absorption order,
|
||||
// sheet material tracking and the separate bodies of a New Body extrusion.
|
||||
if tools.len() > 1
|
||||
&& matches!(f.kind, FeatureKind::Extrude { .. })
|
||||
&& matches!(op, Operation::Join | Operation::Cut)
|
||||
&& !target_idx.is_empty()
|
||||
&& target_idx.iter().all(|i| state.bodies.get(*i).is_some_and(|b| !b.body.is_mesh() && !state.sheets.iter().any(|s| s.body == b.name)))
|
||||
&& let Ok(Some(compound)) = kernel::compound_disjoint_extrusions(&tools.iter().map(|tool| tool.body.clone()).collect::<Vec<_>>())
|
||||
{
|
||||
let target = if target_idx.len() == 1 {
|
||||
target_idx.first().copied()
|
||||
} else if op == Operation::Join {
|
||||
common_join_target(state, &target_idx, &tools).ok().flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The optimization must not reject geometry the original sequential path can
|
||||
// handle. A failed batch leaves the model untouched and retries the original tools.
|
||||
let batch_targets = if op == Operation::Cut {
|
||||
// Subtracting a union equals subtracting its separated tools in order.
|
||||
// Keep ALL original participants; no sampled contact test excludes a body.
|
||||
Some(target_idx.iter().filter_map(|index| state.bodies.get(*index).map(|body| body.name.clone())).collect::<Vec<_>>())
|
||||
} else {
|
||||
target.and_then(|index| state.bodies.get(index)).map(|body| vec![body.name.clone()])
|
||||
};
|
||||
if let Some(names) = batch_targets {
|
||||
let mut batched = state.clone();
|
||||
// Preserve the original participants. For Join, the compound's larger
|
||||
// size must not expand the contact tolerance and absorb a nearby body.
|
||||
if apply_traced(&mut batched, f, vec![Traced::step(compound, &tools.iter().collect::<Vec<_>>())], op, &names).is_ok() {
|
||||
*state = batched;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
match op {
|
||||
Operation::Join => {
|
||||
// Each tool joins every target it meets into one body, kept under the first one's
|
||||
@@ -2141,6 +2223,46 @@ fn extend_for_coplanar(sur: &Surroundings, plane: &Plane, r: &Region2, lo: f64,
|
||||
(lo2, hi2)
|
||||
}
|
||||
|
||||
/// Remove ends of a join tool already wholly inside its targets. A tab extending
|
||||
/// a rib otherwise shares the rib's side faces through its buried portion; on a
|
||||
/// chamfered base those coincident faces can defeat every boolean retry (#2059).
|
||||
/// Only straight profiles are trimmed, after checking the entire swept material
|
||||
/// against the targets' faces, with the same clearance used when extending ends.
|
||||
fn trim_embedded_join_ends(sur: &Surroundings, plane: &Plane, r: &Region2, lo: f64, hi: f64) -> (f64, f64) {
|
||||
if sur.op != Operation::Join
|
||||
|| !std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).all(|s| matches!(s, solidcraft_geom::Seg2::Line { .. }))
|
||||
{
|
||||
return (lo, hi);
|
||||
}
|
||||
let pieces = r.triangulate(1e-6);
|
||||
let covered: f64 = pieces.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).abs() * 0.5).sum();
|
||||
if pieces.is_empty() || (covered - r.area()).abs() > 1e-7 * r.area() {
|
||||
return (lo, hi);
|
||||
}
|
||||
let n = plane.normal();
|
||||
let mut levels: Vec<f64> = sur
|
||||
.bodies
|
||||
.iter()
|
||||
.flat_map(|(_, mesh, _)| {
|
||||
mesh.triangles.iter().filter_map(|t| {
|
||||
let [a, b, c] = mesh.tri(t)?;
|
||||
let normal = (b - a).cross(c - a).normalized()?;
|
||||
(normal.dot(n).abs() > 1.0 - 1e-9).then(|| (a - plane.origin).dot(n))
|
||||
})
|
||||
})
|
||||
.filter(|h| *h > lo + sur.skin && *h < hi - sur.skin)
|
||||
.collect();
|
||||
levels.sort_by(f64::total_cmp);
|
||||
levels.dedup_by(|a, b| (*a - *b).abs() < sur.skin);
|
||||
if levels.len() > 256 {
|
||||
return (lo, hi);
|
||||
}
|
||||
let start = levels.iter().rev().copied().find(|h| sur.harmless(plane, &pieces, -sur.skin, lo + sur.skin, h - sur.skin)).unwrap_or(lo);
|
||||
let end =
|
||||
levels.iter().copied().find(|h| *h > start + sur.skin && sur.harmless(plane, &pieces, -sur.skin, h + sur.skin, hi - sur.skin)).unwrap_or(hi);
|
||||
(start, end)
|
||||
}
|
||||
|
||||
/// Whether a tapered tool, run on by `depth` behind the plane it starts in (`dir` the way it
|
||||
/// extrudes), stays where running on changes nothing: in the targets for a join, out of them for
|
||||
/// a cut. Its sides keep their slope, so the section there is the profile grown by up to `widen`
|
||||
@@ -3179,8 +3301,63 @@ fn feature_tools_with_constructor(
|
||||
// Each tool's extent along the normal (they differ where one is run on into a face).
|
||||
let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len());
|
||||
let plain_targets: Vec<String> = crate::plain(targets);
|
||||
let surroundings = Surroundings::of(st, *operation, &plain_targets);
|
||||
for r in ®ions {
|
||||
let exact_planar_cut = *operation == Operation::Cut
|
||||
&& ss.sketch.links.len() > 1
|
||||
&& ss.sketch.links.iter().all(|link| matches!(&link.source, solidcraft_sketch::LinkSource::Face { .. }))
|
||||
&& st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|body| plain_targets.is_empty() || plain_targets.iter().any(|target| target == "*" || target == &body.name))
|
||||
.all(|body| kernel::is_planar(&body.body));
|
||||
let mut touching_batch = false;
|
||||
// Generated face profiles have full source seams. Check all of them in
|
||||
// one guarded construction; arbitrary sketch profiles retain their existing path.
|
||||
if *operation == Operation::Join
|
||||
&& regions.len() > 1
|
||||
&& ss.sketch.links.len() > 1
|
||||
&& ss.sketch.links.iter().all(|link| matches!(&link.source, solidcraft_sketch::LinkSource::Face { .. }))
|
||||
{
|
||||
let exact = regions
|
||||
.iter()
|
||||
.map(|region| centered_extrude(&ss.plane, region, lo + off, hi + off, Some(&ss)))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
if let Ok(Some(compound)) = kernel::compound_disjoint_extrusions(&exact.iter().map(|tool| tool.body.clone()).collect::<Vec<_>>())
|
||||
&& st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|body| plain_targets.is_empty() || plain_targets.contains(&body.name))
|
||||
.any(|body| kernel::can_join_all_touching_faces(&body.body, &compound))
|
||||
{
|
||||
tools = exact;
|
||||
touching_batch = true;
|
||||
}
|
||||
}
|
||||
let surroundings = if exact_planar_cut || touching_batch { None } else { Surroundings::of(st, *operation, &plain_targets) };
|
||||
let join_surroundings = if *operation == Operation::Join && !touching_batch {
|
||||
// Trimming may discard a tool's material, so only bodies this feature
|
||||
// actually joins can establish that the discarded end is already filled.
|
||||
let participants = if plain_targets.is_empty() { &f.participants } else { &plain_targets };
|
||||
let trim_targets: Vec<String> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
if participants.is_empty() {
|
||||
b.component == f.component || b.feature == f.id
|
||||
} else {
|
||||
participants.iter().any(|t| t == "*" || t == &b.name)
|
||||
}
|
||||
})
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
|
||||
if trim_targets.is_empty() { None } else { Surroundings::of(st, *operation, &trim_targets) }
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for r in regions.iter().filter(|_| !touching_batch) {
|
||||
if *operation == Operation::Join {
|
||||
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
|
||||
if exact.iter().all(|tool| {
|
||||
@@ -3198,6 +3375,12 @@ fn feature_tools_with_constructor(
|
||||
}
|
||||
}
|
||||
let (l2, h2) = surroundings.as_ref().map_or((lo + off, hi + off), |sur| extend_for_coplanar(sur, &ss.plane, r, lo + off, hi + off));
|
||||
let (l2, h2) = join_surroundings.as_ref().map_or((l2, h2), |sur| {
|
||||
// Trim only material in the requested extent. A run-on added
|
||||
// above to resolve a touching cap must keep doing its job.
|
||||
let (l, h) = trim_embedded_join_ends(sur, &ss.plane, r, lo + off, hi + off);
|
||||
(if l > lo + off { l } else { l2 }, if h < hi + off { h } else { h2 })
|
||||
});
|
||||
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
|
||||
let original = r;
|
||||
let grown = |gains: &dyn Fn(&[[Vec2; 3]]) -> bool| match &surroundings {
|
||||
@@ -4522,6 +4705,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::SurfaceLoft { .. }
|
||||
| FeatureKind::SurfaceBlend { .. }
|
||||
| FeatureKind::SurfaceBoundary { .. }
|
||||
| FeatureKind::SurfaceNetwork { .. }
|
||||
| FeatureKind::SurfaceMatch { .. }
|
||||
| FeatureKind::SurfaceFair { .. }
|
||||
| FeatureKind::SurfaceOffset { .. }
|
||||
@@ -4576,8 +4760,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
let profiles = find_profiles(&sk);
|
||||
let ends = solidcraft_sketch::loose_ends(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles }));
|
||||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Extrude { operation, targets, .. }
|
||||
@@ -5734,6 +5919,27 @@ mod body_name_tests {
|
||||
mod coplanar_growth_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn buried_join_ends_stop_at_material_boundaries() {
|
||||
let block = kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 8.0)).unwrap();
|
||||
let profile = rect(2.0, 2.0, 4.0, 4.0);
|
||||
let st = state(vec![block.clone()]);
|
||||
let sur = Surroundings::of(&st, Operation::Join, &[]).unwrap();
|
||||
assert_eq!(trim_embedded_join_ends(&sur, &Plane::XY, &profile, 0.0, 16.0), (8.0, 16.0));
|
||||
assert_eq!(trim_embedded_join_ends(&sur, &Plane::XY, &profile, -8.0, 8.0), (-8.0, 0.0));
|
||||
// A thin sealed void between the old start and the rib's top must still
|
||||
// be filled by the join. The remaining tool must start before that void.
|
||||
let void = kernel::box_solid(Vec3::new(1.0, 1.0, 3.01), Vec3::new(5.0, 5.0, 3.02)).unwrap();
|
||||
let hollow = kernel::boolean(&block, &void, BoolOp::Cut).unwrap().unwrap();
|
||||
let st = state(vec![hollow]);
|
||||
let sur = Surroundings::of(&st, Operation::Join, &[]).unwrap();
|
||||
let (lo, hi) = trim_embedded_join_ends(&sur, &Plane::XY, &profile, 0.0, 16.0);
|
||||
assert!(lo <= 3.01, "trimming past {lo} would discard material the join adds in the void");
|
||||
assert_eq!(hi, 16.0);
|
||||
let cut = Surroundings::of(&st, Operation::Cut, &[]).unwrap();
|
||||
assert_eq!(trim_embedded_join_ends(&cut, &Plane::XY, &profile, 0.0, 16.0), (0.0, 16.0));
|
||||
}
|
||||
|
||||
fn state(bodies: Vec<Body>) -> ModelState {
|
||||
ModelState { bodies: bodies.into_iter().enumerate().map(|(i, b)| ModelBody::new(format!("Body{i}"), b, 0)).collect(), ..Default::default() }
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
apply_op(st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, &crate::plain(targets))
|
||||
}
|
||||
FeatureKind::Rib { sketch, curves, thickness, depth, flip, web } => {
|
||||
FeatureKind::Rib { sketch, curves, thickness, depth, flip, web, normal } => {
|
||||
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
let t = val(vals, thickness, Kind::Length)?;
|
||||
if !(t > 0.0) {
|
||||
@@ -88,7 +88,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
let mut tools = Vec::new();
|
||||
for g in &groups {
|
||||
tools.push(rib_tool(st, &ss, g, t, depth, *flip)?);
|
||||
tools.push(if *normal { rib_tool_normal(st, &ss, g, t, depth, *flip)? } else { rib_tool(st, &ss, g, t, depth, *flip)? });
|
||||
}
|
||||
apply_op(st, f, tools, Operation::Join, &[])
|
||||
}
|
||||
@@ -286,6 +286,83 @@ fn rib_tool(st: &ModelState, ss: &SolvedSketch, ids: &[String], t: f64, depth: O
|
||||
tools.into_iter().next().ok_or_else(|| DocError::Invalid("the rib made no solid".into()))
|
||||
}
|
||||
|
||||
/// A rib wall along the sketch normal: the curves widened to `t` in the sketch plane, run from
|
||||
/// the sketch toward the body by `depth` (or until the whole wall reaches the body).
|
||||
fn rib_tool_normal(st: &ModelState, ss: &SolvedSketch, ids: &[String], t: f64, depth: Option<f64>, flip: bool) -> Result<Body> {
|
||||
let pts = chain_points(ss, ids)?;
|
||||
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
if !(len > 1e-9) {
|
||||
return Err(DocError::Invalid("a zero-length rib curve".into()));
|
||||
}
|
||||
let samples = ((len / (t * 0.5).max(len / 400.0)).ceil() as usize).clamp(2, 400);
|
||||
let along: Vec<Vec3> = (0..=samples).map(|k| ss.plane.to_world(point_on_polyline(&pts, len * k as f64 / samples as f64))).collect();
|
||||
let normal = ss.plane.normal();
|
||||
let reach = st.bodies.iter().map(|b| b.mesh().bounds()).fold(0.0f64, |acc, bb| acc.max((bb.max - bb.min).len()));
|
||||
let inside = |q: Vec3| st.bodies.iter().any(|b| b.mesh().contains(q));
|
||||
// Where the body begins from each point of the curves, along `sign` × the sketch normal.
|
||||
let limit = reach * 2.0 + 1.0;
|
||||
let step = (limit / 400.0).max(1e-3);
|
||||
let hits = |sign: f64| -> Vec<f64> {
|
||||
let mut out = Vec::new();
|
||||
for q in &along {
|
||||
let Some(k) = (1..=400).find(|k| inside(*q + normal * (sign * *k as f64 * step))) else { continue };
|
||||
let (mut lo, mut hi) = ((k - 1) as f64 * step, k as f64 * step);
|
||||
for _ in 0..30 {
|
||||
let m = 0.5 * (lo + hi);
|
||||
if inside(*q + normal * (sign * m)) { hi = m } else { lo = m }
|
||||
}
|
||||
out.push(hi);
|
||||
}
|
||||
out
|
||||
};
|
||||
// Toward the material: the side of the sketch where the curves meet the bodies (unless flipped).
|
||||
let (front, back) = (hits(1.0), hits(-1.0));
|
||||
let mut sign = if back.len() > front.len() { -1.0 } else { 1.0 };
|
||||
let mut reached = if sign > 0.0 { front } else { back };
|
||||
if flip {
|
||||
sign = -sign;
|
||||
reached = hits(sign);
|
||||
}
|
||||
let h = match depth {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let far = reached.iter().copied().fold(f64::NAN, f64::max);
|
||||
if !far.is_finite() {
|
||||
return Err(DocError::Invalid("the rib curves do not face a body along the sketch normal (give a depth)".into()));
|
||||
}
|
||||
far + (t * 0.5).max(step)
|
||||
}
|
||||
};
|
||||
// The curves widened to `t`: each point offset by half the thickness along the miter of its
|
||||
// neighbours, both sides joined into one band.
|
||||
let half = t / 2.0;
|
||||
let mut left = Vec::with_capacity(pts.len());
|
||||
let mut right = Vec::with_capacity(pts.len());
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
let prev = i.checked_sub(1).and_then(|j| pts.get(j)).and_then(|q| (*p - *q).normalized());
|
||||
let next = pts.get(i + 1).and_then(|q| (*q - *p).normalized());
|
||||
let (n, scale) = match (prev, next) {
|
||||
(Some(a), Some(b)) => {
|
||||
let m = (a.perp() + b.perp()).normalized().unwrap_or(a.perp());
|
||||
(m, 1.0 / m.dot(a.perp()).max(0.25))
|
||||
}
|
||||
(Some(a), None) | (None, Some(a)) => (a.perp(), 1.0),
|
||||
(None, None) => continue,
|
||||
};
|
||||
left.push(*p + n * (half * scale));
|
||||
right.push(*p - n * (half * scale));
|
||||
}
|
||||
left.extend(right.into_iter().rev());
|
||||
let mut lp = Loop2::polygon(&left);
|
||||
if lp.signed_area() < 0.0 {
|
||||
lp = lp.reversed();
|
||||
}
|
||||
let region = Region2 { outer: lp, holes: Vec::new() };
|
||||
let (a, b) = if sign > 0.0 { (0.0, h) } else { (-h, 0.0) };
|
||||
let tools = kernel::extrude(&ss.plane, &[region], a, b)?;
|
||||
tools.into_iter().next().ok_or_else(|| DocError::Invalid("the rib made no solid".into()))
|
||||
}
|
||||
|
||||
/// The point `d` along a polyline.
|
||||
fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 {
|
||||
let mut left = d.max(0.0);
|
||||
|
||||
@@ -151,12 +151,49 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
|
||||
let body = kernel::boundary_patch(&boundaries, *crown, *samples)?;
|
||||
// Two curves are the two sides of a bridge. Three or four run round a loop: the Coons
|
||||
// patch of the loop, which lies in the sketch's plane with them, so every order holds.
|
||||
let body = if curves.len() >= 3 {
|
||||
if *crown != 0.0 {
|
||||
return Err(DocError::Invalid(
|
||||
"not supported yet: crown on a patch through three or four curves (it is the flat patch of its sides)".into(),
|
||||
));
|
||||
}
|
||||
loop_patch(&ss, curves)?
|
||||
} else {
|
||||
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
|
||||
kernel::boundary_patch(&boundaries, *crown, *samples)?
|
||||
};
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, body, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceNetwork { u, v, operation } => {
|
||||
if !matches!(operation, crate::Operation::NewBody | crate::Operation::Join) {
|
||||
return Err(DocError::Invalid("a boundary surface is a new body or joins the body of its edges".into()));
|
||||
}
|
||||
let us = network_sources(doc, st, f, u, "U")?;
|
||||
let vs = network_sources(doc, st, f, v, "V")?;
|
||||
let made = kernel::boundary_surface(&network_curves(&us), &network_curves(&vs))?;
|
||||
if *operation == crate::Operation::Join {
|
||||
// Into the body of the first curve picked on a body's edges.
|
||||
let target = u
|
||||
.iter()
|
||||
.chain(v)
|
||||
.find_map(|c| c.body.clone())
|
||||
.ok_or_else(|| DocError::Invalid("join needs a curve on a body's edges: the surface is sewn into that body".into()))?;
|
||||
let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Unknown(format!("body `{target}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let joined = kernel::boundary_join(&mb.body, &made)?;
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, joined, mb.feature);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, made, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceMatch { body, faces, target, edges, order } => {
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
@@ -439,6 +476,135 @@ fn push_sheet(st: &mut ModelState, f: &Feature, traced: crate::lineage::Traced)
|
||||
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
|
||||
}
|
||||
|
||||
/// The most curves one direction of a network may list (the kernel's own cap).
|
||||
const MAX_NETWORK_CURVES: usize = solidcraft_geom::network::MAX_NETWORK_CURVES;
|
||||
|
||||
/// A loop of three or four sketch curves as one patch: four give the Coons patch of opposite pairs
|
||||
/// (the first and third one way, the second and fourth the other); three collapse the side
|
||||
/// opposite the first curve to the corner where the other two meet.
|
||||
fn loop_patch(ss: &SolvedSketch, curves: &[String]) -> Result<kernel::Body> {
|
||||
let mut chains: Vec<Vec<Seg2>> = Vec::with_capacity(curves.len());
|
||||
for id in curves {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("sketch curve `{id}`")))?;
|
||||
let segs = ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci));
|
||||
if segs.is_empty() {
|
||||
return Err(DocError::Invalid(format!("curve `{id}` has no geometry to build on")));
|
||||
}
|
||||
chains.push(segs);
|
||||
}
|
||||
let side = |k: usize| -> Result<kernel::BoundaryCurve<'static>> {
|
||||
let (Some(id), Some(segs)) = (curves.get(k), chains.get(k)) else { return Err(DocError::Invalid("a boundary curve is missing".into())) };
|
||||
Ok(kernel::BoundaryCurve {
|
||||
label: format!("curve `{id}`"),
|
||||
source: kernel::CurveSource::Sketch { plane: ss.plane, segs: segs.clone() },
|
||||
continuity: kernel::Continuity::G0,
|
||||
})
|
||||
};
|
||||
match curves.len() {
|
||||
4 => Ok(kernel::boundary_surface(&[side(0)?, side(2)?], &[side(1)?, side(3)?])?),
|
||||
3 => {
|
||||
// The corner the second and third curves share, whichever way each was drawn.
|
||||
let ends = |k: usize| -> Option<[Vec2; 2]> {
|
||||
let c = chains.get(k)?;
|
||||
Some([c.first()?.start(), c.last()?.end()])
|
||||
};
|
||||
let (Some(b), Some(c)) = (ends(1), ends(2)) else { return Err(DocError::Invalid("a boundary curve is missing".into())) };
|
||||
let mut best = (f64::INFINITY, Vec2::new(0.0, 0.0));
|
||||
for p in b {
|
||||
for q in c {
|
||||
if p.dist(q) < best.0 {
|
||||
best = (p.dist(q), (p + q) * 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
let apex = kernel::BoundaryCurve {
|
||||
label: "the corner opposite the first curve".into(),
|
||||
source: kernel::CurveSource::Point(ss.plane.to_world(best.1)),
|
||||
continuity: kernel::Continuity::G0,
|
||||
};
|
||||
Ok(kernel::boundary_surface(&[side(0)?, apex], &[side(1)?, side(2)?])?)
|
||||
}
|
||||
_ => Err(DocError::Invalid("a loop patch takes three or four curves".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A network curve resolved in the feature's frame, owning what it is built from.
|
||||
enum NetSource {
|
||||
Sketch(solidcraft_geom::Plane, Vec<Seg2>),
|
||||
Points(Vec<Vec3>),
|
||||
Edges(kernel::Body, Vec<Vec3>),
|
||||
Point(Vec3),
|
||||
}
|
||||
|
||||
/// The curves of one direction of a network, resolved: each one sketch chain, 3D sketch chain,
|
||||
/// edge chain or point, labelled `U1`, `U2`, … (with `dir` "U") for messages.
|
||||
fn network_sources(
|
||||
doc: &Document,
|
||||
st: &ModelState,
|
||||
f: &Feature,
|
||||
list: &[crate::NetworkCurve],
|
||||
dir: &str,
|
||||
) -> Result<Vec<(String, NetSource, kernel::Continuity)>> {
|
||||
if list.len() > MAX_NETWORK_CURVES {
|
||||
return Err(DocError::Invalid(format!("at most {MAX_NETWORK_CURVES} curves each way")));
|
||||
}
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for (k, c) in list.iter().enumerate() {
|
||||
let label = format!("{dir}{}", k + 1);
|
||||
let given = [c.sketch.is_some() || !c.curves.is_empty(), c.body.is_some() || !c.edges.is_empty(), c.point.is_some()];
|
||||
if given.iter().filter(|g| **g).count() != 1 {
|
||||
return Err(DocError::Invalid(format!("{label} must be sketch curves, a body's edges or a point")));
|
||||
}
|
||||
let source = if let Some(p) = c.point {
|
||||
NetSource::Point(p.0)
|
||||
} else if let Some(name) = &c.body {
|
||||
if c.edges.is_empty() {
|
||||
return Err(DocError::Invalid(format!("{label} needs points on the edges of {name}")));
|
||||
}
|
||||
let b = st.body(name).ok_or_else(|| DocError::Unknown(format!("body `{name}`")))?;
|
||||
NetSource::Edges(b.body.clone(), crate::plain(&c.edges))
|
||||
} else {
|
||||
let sk = c.sketch.ok_or_else(|| DocError::Invalid(format!("{label} needs the sketch its curves are in")))?;
|
||||
if c.curves.is_empty() || c.curves.len() > MAX_CHAIN_CURVES {
|
||||
return Err(DocError::Invalid(format!("{label} needs 1 to {MAX_CHAIN_CURVES} curves of its sketch")));
|
||||
}
|
||||
let raw = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
|
||||
// 3D sketch curves are followed through their points; planar ones exactly.
|
||||
let mapped = path_in_frame(doc, raw, f.component)?;
|
||||
match path_points(&mapped, &c.curves, Vec3::new(f64::NAN, f64::NAN, f64::NAN))? {
|
||||
Some(pts) => NetSource::Points(pts),
|
||||
None => {
|
||||
let ss = sketch_in_feature_frame(doc, raw, f.component)?;
|
||||
let mut chains = curve_chains(&ss, &c.curves)?.into_iter();
|
||||
let (Some(chain), None) = (chains.next(), chains.next()) else {
|
||||
return Err(DocError::Invalid(format!("{label}: its curves must meet end to end in one chain")));
|
||||
};
|
||||
NetSource::Sketch(ss.plane, chain)
|
||||
}
|
||||
}
|
||||
};
|
||||
out.push((label, source, c.continuity));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The kernel's curves over resolved sources (borrowing their bodies).
|
||||
fn network_curves(sources: &[(String, NetSource, kernel::Continuity)]) -> Vec<kernel::BoundaryCurve<'_>> {
|
||||
sources
|
||||
.iter()
|
||||
.map(|(label, src, g)| kernel::BoundaryCurve {
|
||||
label: label.clone(),
|
||||
source: match src {
|
||||
NetSource::Sketch(plane, segs) => kernel::CurveSource::Sketch { plane: *plane, segs: segs.clone() },
|
||||
NetSource::Points(pts) => kernel::CurveSource::Points(pts.clone()),
|
||||
NetSource::Edges(body, at) => kernel::CurveSource::Edges { body, at: at.clone() },
|
||||
NetSource::Point(p) => kernel::CurveSource::Point(*p),
|
||||
},
|
||||
continuity: *g,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The boundaries `surface.boundary` builds its patch from: each picked curve's own stations in
|
||||
/// the sketch's frame, every one of them leaving the boundary square into the loop — the direction
|
||||
/// from the curve towards the middle of the loop, taken across its tangent — over the plane the
|
||||
|
||||
@@ -164,6 +164,10 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
// The Class-A surface family (surface.blend/boundary/match/fair).
|
||||
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
|
||||
("surface_boundary", json!({"type": "surface_boundary", "sketch": 1, "curves": ["l1", "l2"]})),
|
||||
(
|
||||
"surface_network",
|
||||
json!({"type": "surface_network", "u": [{"sketch": 1, "curves": ["l1"]}, {"body": "B", "edges": [o], "continuity": "g1"}], "v": [{"sketch": 2, "curves": ["l1"]}, {"point": o}], "operation": "join"}),
|
||||
),
|
||||
("surface_match", json!({"type": "surface_match", "body": "B", "faces": [o], "target": "C", "edges": [o]})),
|
||||
("surface_fair", json!({"type": "surface_fair", "body": "B", "faces": [o], "tolerance": "sfair.tolerance"})),
|
||||
("unstitch", json!({"type": "unstitch", "body": "B"})),
|
||||
|
||||
@@ -342,6 +342,7 @@ surface_offset
|
||||
expressions: soff.distance, soff.tolerance
|
||||
surface_blend
|
||||
surface_boundary
|
||||
surface_network
|
||||
surface_match
|
||||
surface_fair
|
||||
inputs:
|
||||
|
||||
@@ -157,6 +157,8 @@ surface_offset
|
||||
surface_blend
|
||||
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
|
||||
surface_boundary
|
||||
surface_network
|
||||
moves: u[1].edges[0][0], u[1].edges[0][1], u[1].edges[0][2], v[1].point[0], v[1].point[1], v[1].point[2]
|
||||
surface_match
|
||||
moves: edges[0][0], edges[0][1], edges[0][2], faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
|
||||
@@ -182,6 +182,10 @@ surface_blend
|
||||
surface_boundary
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
surface_network
|
||||
sketches: [1, 2]
|
||||
remap_refs: u[0].sketch, v[0].sketch
|
||||
remap_bodies: u[1].body
|
||||
surface_match
|
||||
remap_bodies: body
|
||||
surface_fair
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! A `SurfaceNetwork` (Boundary Surface through a curve network, #763) saves `u` and `v`, each a
|
||||
//! list of curves: a `sketch` and its `curves`, a `body` and points on its `edges`, or a `point`
|
||||
//! (the apex of a three-sided patch), with an optional `continuity` (`g1` or `g2` with the face
|
||||
//! beside an edge chain), and an `operation` (`new_body`, or `join` into the body of its edges).
|
||||
//! A `SurfaceBoundary` of three or four curves, which older builds refused, now builds and saves
|
||||
//! as it always did. Older designs need no transformation; the last release asks for an update
|
||||
//! instead of meeting a feature it can't read.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A Rib or Web can grow along the sketch normal (`normal: true`): the wall is `thickness` wide
|
||||
//! across its curves in the sketch plane and runs from the sketch toward the body. Older designs
|
||||
//! need no transformation: a rib without `normal` grows across the sketch plane as before; 0.14.x
|
||||
//! asks for an update instead of building a normal rib in the plane.
|
||||
@@ -17,6 +17,7 @@ pub use clipboard::{clone_expression, remap_topology};
|
||||
pub mod config;
|
||||
pub mod construct;
|
||||
pub mod display_quality;
|
||||
pub mod display_units;
|
||||
mod document;
|
||||
mod eval;
|
||||
pub mod expr;
|
||||
|
||||
@@ -370,7 +370,7 @@ mod tests {
|
||||
sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap();
|
||||
let report = solve(&mut sketch);
|
||||
let profiles = find_profiles(&sketch);
|
||||
let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles };
|
||||
let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles, ends: Default::default() };
|
||||
assert_eq!(ss.profiles.len(), 1);
|
||||
let indices = vec![0; 600];
|
||||
let selection = ProfileSel::Indices { indices: indices.clone() };
|
||||
|
||||
@@ -45,6 +45,64 @@ fn volume(s: &Session, body: &str) -> f64 {
|
||||
s.model.state().body(body).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// #2059: tabs exactly as wide as their supporting ribs share the ribs' side
|
||||
/// planes. The base's remote conical chamfer must not prevent their join.
|
||||
#[test]
|
||||
fn eighteen_tabs_join_the_ribs_of_a_chamfered_base() {
|
||||
let s = replay(include_str!("../../../examples/regressions/tabs_on_chamfered_ribbed_base.json"));
|
||||
let check = |s: &Session| {
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let state = s.model.state();
|
||||
let body = &state.body("Base").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||||
let mesh = body.tessellate(1e-4).unwrap();
|
||||
// R40 x 4 disc less its 1 x 1 chamfer, six 32 x 2 ribs
|
||||
// standing 4 above it, and eighteen 1 x 2 tabs another 8 higher.
|
||||
let expected = std::f64::consts::PI * (6400.0 - 40.0 + 1.0 / 3.0) + 1536.0 + 288.0;
|
||||
let got = mesh.measure().volume;
|
||||
assert!((got - expected).abs() < 0.1, "{got} vs {expected}");
|
||||
println!("#2059 volume {got:.6} / {expected:.6} mm³; one valid shell, eighteen tabs");
|
||||
for k in 0..6 {
|
||||
let angle = k as f64 * std::f64::consts::TAU / 6.0;
|
||||
let p = |x: f64, y: f64, z| solidcraft_geom::Vec3::new(x * angle.cos() - y * angle.sin(), x * angle.sin() + y * angle.cos(), z);
|
||||
for x in [15.5, 25.5, 35.5] {
|
||||
assert!(mesh.contains(p(x, 0.0, 12.0)), "tab {k} at {x}");
|
||||
assert!(!mesh.contains(p(x, 1.2, 12.0)), "tab {k} grew sideways at {x}");
|
||||
}
|
||||
assert!(mesh.contains(p(20.0, 0.0, 6.0)), "rib {k}");
|
||||
assert!(mesh.contains(p(39.5, 0.0, 2.0)), "rim {k}");
|
||||
assert!(!mesh.contains(p(39.75, 0.0, 3.75)), "chamfer {k}");
|
||||
}
|
||||
assert!((mesh.bounds().max.z - 16.0).abs() < 1e-8);
|
||||
};
|
||||
check(&s);
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check(&reopened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buried_join_end_ignores_bodies_in_other_components() {
|
||||
for own_body in [false, true] {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, ¶ms).unwrap();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 8, "body_name": "Other"}));
|
||||
run(&mut s, "component.create", json!({"name": "Child"}));
|
||||
if own_body {
|
||||
run(&mut s, "solid.box", json!({"corner": [50, 0, 0], "length": 10, "width": 10, "height": 8, "body_name": "Child"}));
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 2], "p1": [4, 4]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 16, "operation": "join", "body_name": "Tab"}));
|
||||
let got = volume(&s, if own_body { "Child" } else { "Tab" });
|
||||
let expected = 64.0 + if own_body { 800.0 } else { 0.0 };
|
||||
assert!((got - expected).abs() < 1e-6, "{got}");
|
||||
assert!((volume(&s, "Other") - 800.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to
|
||||
/// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)).
|
||||
fn slab_pocket_loft() -> (f64, f64) {
|
||||
@@ -475,3 +533,278 @@ fn box_intersect_splits_the_end_off_a_raked_wedge_after_its_end_rounds() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1853: half a slot drawn on a plate's top face, cut along its centreline, revolved about a
|
||||
/// construction line on that centreline as a cut. Half a turn into the plate and a full turn
|
||||
/// both failed with "no result": the revolve came out wrong where its arcs end on the axis (a
|
||||
/// fifth too much or too little), and the cut could not intersect it there. Both now take the
|
||||
/// half of the capsule below the face.
|
||||
#[test]
|
||||
fn revolve_cut_of_a_half_slot_about_its_centreline_in_the_face() {
|
||||
let pi = std::f64::consts::PI;
|
||||
let inside = (pi * 9.0 * 14.0 + 4.0 / 3.0 * pi * 27.0) / 2.0;
|
||||
for angle in ["-180 deg", "360 deg"] {
|
||||
let mut s = Session::default();
|
||||
let steps = [
|
||||
("solid.box", json!({"corner": [0, 0, 0], "length": 40, "width": 20, "height": 10, "body_name": "Plate"})),
|
||||
("sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Half"})),
|
||||
("sketch.line", json!({"points": [[10, 10], [30, 10]]})),
|
||||
("sketch.line", json!({"points": [[27, 13], [13, 13]]})),
|
||||
("sketch.arc.center_point", json!({"center": [13, 10], "start": [13, 13], "end": [10, 10]})),
|
||||
("sketch.arc.center_point", json!({"center": [27, 10], "start": [30, 10], "end": [27, 13]})),
|
||||
("sketch.line", json!({"points": [[5, 10], [35, 10]], "construction": true, "ids": ["axis"]})),
|
||||
("sketch.finish", json!({})),
|
||||
];
|
||||
for (command, params) in steps {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"));
|
||||
}
|
||||
let whole = volume(&s, "Plate");
|
||||
s.execute("solid.revolve", &json!({"sketch": "Half", "axis": "axis", "angle": angle, "operation": "cut"}))
|
||||
.unwrap_or_else(|e| panic!("revolve {angle}: {e}"));
|
||||
let bad = s.model.state().body("Plate").map(|b| b.body.validity()).unwrap_or_default();
|
||||
assert!(bad.is_empty(), "{angle}: {bad:?}");
|
||||
let removed = whole - volume(&s, "Plate");
|
||||
println!("#1853 revolve {angle}: removed {removed:.6} mm³ of the {inside:.6} below the face");
|
||||
assert!((removed - inside).abs() < 1e-3 * inside, "{angle}: removed {removed} instead of {inside}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2005: changing a circular sweep from a separate body to a half-turn cut must create the
|
||||
/// internal channel, rather than failing the Boolean on coincident end caps.
|
||||
#[test]
|
||||
fn closed_sweep_redefines_to_a_half_turn_torus_cut() {
|
||||
let s = replay(include_str!("../../../examples/regressions/closed_sweep_torus_cut.json"));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let body = &s.model.state().bodies[0].body;
|
||||
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||||
let mesh = body.tessellate(0.02).unwrap();
|
||||
let expected = (std::f64::consts::PI * 64.0 - 64.0) * std::f64::consts::TAU * 30.0;
|
||||
assert!((mesh.measure().volume - expected).abs() < expected * 0.015);
|
||||
for (point, material) in [(solidcraft_geom::Vec3::new(30.0, 0.0, 0.0), false), (solidcraft_geom::Vec3::new(30.0, 0.0, 7.0), true)] {
|
||||
assert_eq!(mesh.contains(point), material, "material at {point:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_extrusion_profiles_preserve_material_and_body_identity() {
|
||||
for operation in ["join", "cut", "new"] {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20, "body_name": "Stock"})).unwrap();
|
||||
s.execute("sketch.create", &json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})).unwrap();
|
||||
for i in 0..6 {
|
||||
for j in 0..4 {
|
||||
let (x, y) = (2 + i * 6, 2 + j * 7);
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [x, y], "p1": [x + 4, y + 4]})).unwrap();
|
||||
}
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
let distance = if operation == "cut" { -5 } else { 5 };
|
||||
let before = s.revision;
|
||||
s.execute("solid.extrude", &json!({"distance": distance, "operation": operation})).unwrap();
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), if operation == "new" { 25 } else { 1 });
|
||||
let expected = 24_000.0
|
||||
+ match operation {
|
||||
"join" => 1920.0,
|
||||
"cut" => -1920.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
assert!((volume(&s, "Stock") - expected).abs() < 1e-6);
|
||||
let stock = state.body("Stock").unwrap();
|
||||
for (point, present) in [([3.0, 3.0, 22.0], operation == "join"), ([3.0, 3.0, 18.0], operation != "cut"), ([7.0, 3.0, 18.0], true)] {
|
||||
assert_eq!(stock.mesh().contains(solidcraft_geom::Vec3::from(point)), present, "{operation}: {point:?}");
|
||||
}
|
||||
assert!(stock.body.validity().is_empty());
|
||||
assert!(s.revision > before);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((volume(&s, "Stock") - 24_000.0).abs() < 1e-6);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!((volume(&s, "Stock") - expected).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_circular_extrusion_profiles_cut_a_curved_target() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.cylinder", &json!({"radius": 20, "height": 20, "body_name": "Stock"})).unwrap();
|
||||
s.execute("sketch.create", &json!({"plane": {"origin": [0, 0, -1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})).unwrap();
|
||||
for x in [-9, -3, 3, 9] {
|
||||
for y in [-9, -3, 3, 9] {
|
||||
s.execute("sketch.circle.center", &json!({"center": [x, y], "radius": 1})).unwrap();
|
||||
}
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
s.execute("solid.extrude", &json!({"distance": 22, "operation": "cut", "targets": ["Stock"]})).unwrap();
|
||||
// The rebuilt caps mesh more finely than the original cylinder. Compare with the
|
||||
// analytic material volume, rather than subtracting two differently faceted meshes.
|
||||
let expected = (400.0 - 16.0) * std::f64::consts::PI * 20.0;
|
||||
let actual = volume(&s, "Stock");
|
||||
assert!((actual - expected).abs() < expected * 1e-5, "{actual} vs {expected}");
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), 1);
|
||||
let stock = state.body("Stock").unwrap();
|
||||
assert!(stock.body.validity().is_empty());
|
||||
let mesh = stock.mesh();
|
||||
assert!(mesh.contains(solidcraft_geom::Vec3::new(0.0, 0.0, 10.0)));
|
||||
for x in [-9.0, -3.0, 3.0, 9.0] {
|
||||
for y in [-9.0, -3.0, 3.0, 9.0] {
|
||||
assert!(!mesh.contains(solidcraft_geom::Vec3::new(x, y, 10.0)), "hole at {x}, {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coplanar_body_faces_extrude_together_and_replay_their_links() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":64,"width":64,"height":20,"body_name":"Plate"})).unwrap();
|
||||
s.execute("sketch.create", &json!({"plane":{"origin":[0,0,20],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
|
||||
let mut faces = Vec::new();
|
||||
for i in 0..8 {
|
||||
for j in 0..8 {
|
||||
let (x, y) = (2 + i * 7, 2 + j * 7);
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0":[x,y],"p1":[x+4,y+4]})).unwrap();
|
||||
faces.push(json!([x + 2, y + 2, 26]));
|
||||
}
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
s.execute("solid.extrude", &json!({"distance":6,"operation":"join"})).unwrap();
|
||||
let (features, undo) = (s.doc.features.len(), s.undo.len());
|
||||
s.execute("solid.extrude", &json!({"faces":faces,"distance":-7,"operation":"cut"})).unwrap();
|
||||
assert_eq!(s.doc.features.len(), features + 2, "one linked sketch and one extrusion");
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let actual = volume(&s, "Plate");
|
||||
let state = s.model.state();
|
||||
let sketch = state.sketch(4).unwrap();
|
||||
assert!((actual - 80_896.0).abs() < 1e-5, "volume {actual}; plane {:?}; profiles {}", sketch.plane, sketch.profiles.len());
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let document = serde_json::from_value(serde_json::to_value(s.doc.as_ref()).unwrap()).unwrap();
|
||||
let mut reopened = Session { doc: std::sync::Arc::new(document), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!((volume(&reopened, "Plate") - 80_896.0).abs() < 1e-5);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.features.len(), features);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!((volume(&s, "Plate") - 80_896.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_face_groups_are_atomic() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
let before = serde_json::to_value(s.doc.as_ref()).unwrap();
|
||||
let (revision, undo) = (s.revision, s.undo.len());
|
||||
for faces in [
|
||||
json!([]),
|
||||
json!([[5, 5, 10], [5, 5, 0]]),
|
||||
json!([[5, 5, 10], [5, 0, 5]]),
|
||||
json!([[5, 5, 10], [1000, 1000, 1000]]),
|
||||
json!(vec![[5, 5, 10]; 1025]),
|
||||
] {
|
||||
assert!(s.execute("solid.extrude", &json!({"faces":faces,"distance":5,"operation":"join"})).is_err());
|
||||
assert_eq!(serde_json::to_value(s.doc.as_ref()).unwrap(), before);
|
||||
assert_eq!((s.revision, s.undo.len()), (revision, undo));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_rotated_face_extrusions_preserve_other_operations_and_bodies() {
|
||||
use solidcraft_geom::Vec3;
|
||||
let mut source = Session::default();
|
||||
source.execute("solid.box", &json!({"corner":[-16,-16,-3],"length":32,"width":32,"height":1,"body_name":"Other"})).unwrap();
|
||||
source.execute("solid.cylinder", &json!({"radius":14,"height":8,"body_name":"Stock"})).unwrap();
|
||||
let mut outlines = Vec::new();
|
||||
let mut centers = Vec::new();
|
||||
for i in 0..12 {
|
||||
let angle = i as f64 * std::f64::consts::TAU / 12.;
|
||||
let (c, s) = (angle.cos(), angle.sin());
|
||||
let outline: Vec<_> =
|
||||
[(-2., -2.), (2., -2.), (2., 2.), (-2., 2.)].into_iter().map(|(x, y)| [10.7 * c + x * c - y * s, 10.7 * s + x * s + y * c]).collect();
|
||||
outlines.push(outline);
|
||||
centers.push([10.7 * c, 10.7 * s, 12.]);
|
||||
}
|
||||
let draw = |session: &mut Session, height: f64| {
|
||||
session.execute("sketch.create", &json!({"plane":{"origin":[0,0,height],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
|
||||
for outline in &outlines {
|
||||
session.execute("sketch.line", &json!({"points":outline,"closed":true,"infer":false})).unwrap();
|
||||
}
|
||||
session.execute("sketch.finish", &json!({})).unwrap();
|
||||
};
|
||||
draw(&mut source, 8.);
|
||||
source.execute("solid.extrude", &json!({"distance":4,"operation":"join"})).unwrap();
|
||||
// An original public-safe STEP example, with rotated full-face seams and a
|
||||
// curved target. No vendor/user design is part of this regression.
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-face-extrusion-{}.step", std::process::id()));
|
||||
source.execute("file.export", &json!({"path":path.to_string_lossy()})).unwrap();
|
||||
let mut imported = Session::default();
|
||||
imported.execute("doc.open", &json!({"path":path.to_string_lossy()})).unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
let original = imported.model.state();
|
||||
let other = original.body("Other").unwrap().body.clone();
|
||||
for (operation, distance) in [("join", 2.), ("cut", -1.), ("new", 2.), ("intersect", -1.)] {
|
||||
let mut grouped = imported.scratch();
|
||||
grouped.execute("solid.extrude", &json!({"faces":centers,"distance":distance,"operation":operation})).unwrap();
|
||||
let mut reference = imported.scratch();
|
||||
draw(&mut reference, 12.);
|
||||
if matches!(operation, "join" | "cut") {
|
||||
// Individual tools retain the original Boolean and participant path.
|
||||
for index in 0..12 {
|
||||
reference
|
||||
.execute("solid.extrude", &json!({"sketch":"Sketch1","profiles":[index],"distance":distance,"operation":operation}))
|
||||
.unwrap();
|
||||
}
|
||||
} else {
|
||||
reference.execute("solid.extrude", &json!({"distance":distance,"operation":operation})).unwrap();
|
||||
}
|
||||
let actual = grouped.model.state();
|
||||
let expected = reference.model.state();
|
||||
assert_eq!(actual.bodies.len(), expected.bodies.len(), "{operation}");
|
||||
for body in &actual.bodies {
|
||||
let reference = expected.body(&body.name).unwrap();
|
||||
// Fixed fine chord tolerance: cap subdivision can change the default
|
||||
// adaptive mesh's cylinder faceting without changing its analytic surface.
|
||||
let (a, b) = (body.body.tessellate(1e-5).unwrap().measure().volume, reference.body.tessellate(1e-5).unwrap().measure().volume);
|
||||
assert!((a - b).abs() < b.max(1.) * 1e-6, "{operation}: {} {a} vs {b}", body.name);
|
||||
assert!(body.body.validity().is_empty(), "{operation}: {}", body.name);
|
||||
let (a, b) = (body.mesh(), reference.mesh());
|
||||
for center in ¢ers {
|
||||
for z in [7., 11.5, 13.] {
|
||||
let point = Vec3::new(center[0], center[1], z);
|
||||
assert_eq!(a.contains(point), b.contains(point), "{operation}: {} {point:?}", body.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if operation != "intersect" {
|
||||
let unchanged = &actual.body("Other").unwrap().body;
|
||||
assert_eq!(unchanged.face_count(), other.face_count());
|
||||
assert_eq!(unchanged.color(), other.color());
|
||||
assert_eq!(unchanged.tessellate(0.01).unwrap().positions, other.tessellate(0.01).unwrap().positions, "unaffected imported body");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_target_join_keeps_nearby_bodies_and_original_absorption_order() {
|
||||
for (second_x, expected_count) in [(10.0015, 2), (9., 1)] {
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap();
|
||||
session.execute("solid.box", &json!({"corner":[second_x,8,10],"length":1,"width":1,"height":2,"body_name":"Other"})).unwrap();
|
||||
session.execute("sketch.create", &json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
|
||||
for origin in [[0., 0.], [8., 8.]] {
|
||||
session.execute("sketch.rectangle.two_point", &json!({"p0":origin,"p1":[origin[0]+2.,origin[1]+2.]})).unwrap();
|
||||
}
|
||||
session.execute("sketch.finish", &json!({})).unwrap();
|
||||
let mut reference = session.scratch();
|
||||
for index in 0..2 {
|
||||
reference.execute("solid.extrude", &json!({"sketch":"Sketch1","profiles":[index],"distance":2,"operation":"join"})).unwrap();
|
||||
}
|
||||
session.execute("solid.extrude", &json!({"distance":2,"operation":"join"})).unwrap();
|
||||
assert_eq!(session.model.state().bodies.len(), expected_count);
|
||||
assert_eq!(
|
||||
session.model.state().bodies.iter().map(|body| body.name.clone()).collect::<Vec<_>>(),
|
||||
reference.model.state().bodies.iter().map(|body| body.name.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
for body in &session.model.state().bodies {
|
||||
assert!((volume(&session, &body.name) - volume(&reference, &body.name)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -978,6 +978,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.chain(super::features_more::COMMANDS)
|
||||
.chain(super::construct::COMMANDS)
|
||||
.chain(super::surface::COMMANDS)
|
||||
.chain(super::surface_boundary::COMMANDS)
|
||||
.any(|candidate| candidate.id == spec.id)
|
||||
|| matches!(
|
||||
spec.id,
|
||||
@@ -1049,7 +1050,10 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
*after = before.clone();
|
||||
}
|
||||
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
|
||||
// A boundary surface's loop of sketch curves and its curve network are one command's two
|
||||
// forms: an edit may turn one into the other.
|
||||
let boundary = |k: &FeatureKind| matches!(k, FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. });
|
||||
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) && !(boundary(&new.kind) && boundary(&old.kind)) {
|
||||
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
|
||||
}
|
||||
// Optional inputs can move other inputs' positions (e.g. Distance 2 goes before Taper).
|
||||
|
||||
@@ -84,6 +84,10 @@ type FacePick = (std::sync::Arc<Mesh>, u32, f64, String);
|
||||
/// equally near (bodies touching flush), the pick is ambiguous: `Err` names those bodies.
|
||||
fn face_at_in(s: &Session, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
|
||||
let st = s.model.state();
|
||||
face_at_state(&st, p, only)
|
||||
}
|
||||
|
||||
pub(super) fn face_at_state(st: &solidcraft_doc::ModelState, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
|
||||
let mut best: Option<FacePick> = None;
|
||||
let mut tied: Vec<String> = Vec::new();
|
||||
for b in st.bodies.iter().filter(|b| only.is_none_or(|n| b.name == n)) {
|
||||
@@ -127,6 +131,80 @@ fn xy(v: Vec2) -> Value {
|
||||
json!([v.x, v.y])
|
||||
}
|
||||
|
||||
/// Coplanar faces share one linked profile sketch and one Extrude feature. Resolve every
|
||||
/// pick before editing the document, so later picks never search an already modified body.
|
||||
pub(super) fn sketch_of_faces(s: &mut Session, points: &[Vec3], cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec<Vec2>, String)> {
|
||||
let first = points.first().copied().ok_or_else(|| bad(cmd, "faces must not be empty"))?;
|
||||
if points.len() > 1024 {
|
||||
return Err(bad(cmd, "faces exceed the 1024-face limit"));
|
||||
}
|
||||
let state = s.model.state();
|
||||
let mut picked = Vec::new();
|
||||
let mut common: Option<(String, solidcraft_geom::Plane)> = None;
|
||||
for &point in points {
|
||||
if !point.is_finite() {
|
||||
return Err(bad(cmd, "faces must be finite points"));
|
||||
}
|
||||
let (mesh, index, distance, name) = face_at_in(s, point, face_body)
|
||||
.map_err(|_| bad(cmd, "ambiguous face; specify face_body"))?
|
||||
.ok_or_else(|| bad(cmd, "no body face at a selected point"))?;
|
||||
let body = state.body(&name).ok_or_else(|| bad(cmd, "selected body disappeared"))?;
|
||||
let plane = body.body.planar_face(index as usize).ok_or_else(|| bad(cmd, "faces must be planar"))?;
|
||||
let tolerance = (body.body.size() * 1e-9).max(1e-8);
|
||||
if distance > (mesh.bounds().diagonal() * 1e-3).max(1e-3) * 10.0 {
|
||||
return Err(bad(cmd, "no body face at a selected point"));
|
||||
}
|
||||
if let Some((previous, base)) = &common {
|
||||
if *previous != name || base.normal().dot(plane.normal()) < 1.0 - 1e-9 || base.height(plane.origin).abs() > tolerance {
|
||||
return Err(bad(cmd, "faces must be coplanar, equally oriented faces of one body"));
|
||||
}
|
||||
} else {
|
||||
common = Some((name.clone(), plane));
|
||||
}
|
||||
if picked.iter().any(|(face, _)| *face == index as usize) {
|
||||
continue;
|
||||
}
|
||||
let inside = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, face)| **face == index)
|
||||
.filter_map(|(triangle, _)| mesh.tri(triangle))
|
||||
.max_by(|[a, b, c], [x, y, z]| ((*b - *a).cross(*c - *a).len()).total_cmp(&((*y - *x).cross(*z - *x).len())))
|
||||
.map(|[a, b, c]| (a + b + c) / 3.0)
|
||||
.ok_or_else(|| bad(cmd, "degenerate face"))?;
|
||||
picked.push((index as usize, inside));
|
||||
}
|
||||
let (id, first_inside, _) = sketch_of_face(s, first, cmd, face_body)?;
|
||||
let (body, _) = common.ok_or_else(|| bad(cmd, "no faces selected"))?;
|
||||
let doc = s.placed_document();
|
||||
let source = state.body(&body).ok_or_else(|| bad(cmd, "source body disappeared"))?;
|
||||
let component = doc.model_body_component(source);
|
||||
let path = if component == s.active_component {
|
||||
s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(component))
|
||||
} else {
|
||||
let placements = super::component::properties::placements(&doc, cmd)?;
|
||||
if placements.iter().filter(|(owner, _, _)| *owner == component).count() != 1 {
|
||||
return Err(bad(cmd, "activate the selected occurrence before extruding its faces"));
|
||||
}
|
||||
doc.first_path(component)
|
||||
};
|
||||
let placement = EmbossFace::component_frame(&doc, component, &path)?;
|
||||
let placement = doc.model_body_offset(source).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement);
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component));
|
||||
let author = EmbossFace::component_frame(&doc, s.active_component, &author_path)?;
|
||||
let inverse = mat_inverse(&author).ok_or_else(|| bad(cmd, "invalid face profile author frame"))?;
|
||||
let into_author = mat_mul(&inverse, &placement);
|
||||
drop(doc);
|
||||
let indices: Vec<_> = picked.iter().skip(1).map(|(index, _)| *index).collect();
|
||||
super::sketch_project::project_internal_faces(s, id, &body, &indices)?;
|
||||
s.refresh();
|
||||
let plane = s.model.state().sketch(id).map(|sketch| sketch.plane).ok_or_else(|| bad(cmd, "face profile sketch disappeared"))?;
|
||||
let mut selected = vec![first_inside];
|
||||
selected.extend(picked.iter().skip(1).map(|(_, point)| plane.to_local(apply_point(&into_author, *point))));
|
||||
Ok((id, selected, body))
|
||||
}
|
||||
|
||||
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
|
||||
/// the sketch id, a point inside the face (sketch coordinates), and the picked body's name.
|
||||
/// The sketch is left active.
|
||||
|
||||
@@ -18,7 +18,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
@@ -118,7 +118,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
|
||||
/// face, and the unit normal.
|
||||
fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
|
||||
if let Some(fp) = p.get("face").and_then(vec3) {
|
||||
if let Some(fp) = p.get("face").or_else(|| p.get("faces").and_then(Value::as_array).and_then(|faces| faces.first())).and_then(vec3) {
|
||||
return Some((fp, super::face::face_normal_in(s, fp, str_(p, "face_body"))?));
|
||||
}
|
||||
let sid = feature_sketch(s, p, "Extrude").ok()?;
|
||||
@@ -167,19 +167,38 @@ const START_PROBE_MM: f64 = 0.1;
|
||||
/// direction it grows, or halfway along it. With the halfway point alone, a cut dragged far
|
||||
/// past a body's far side read as a Join (#1257).
|
||||
pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
|
||||
let d = extrude_reach(s, p)?;
|
||||
auto_operation_in(s, p, true)
|
||||
}
|
||||
|
||||
fn auto_operation_in(s: &Session, p: &Value, world_faces: bool) -> Option<&'static str> {
|
||||
let symmetric = p.get("to").is_none() && str_(p, "direction").is_some_and(|d| d.eq_ignore_ascii_case("symmetric"));
|
||||
let (base, n) = extrude_base(s, p)?;
|
||||
let st = s.model.state();
|
||||
// Imported face groups can belong to a child of an empty active component.
|
||||
// Probe in the same coordinate frame as the face picks.
|
||||
let grouped = p.get("faces").and_then(Value::as_array).and_then(|faces| faces.first()).and_then(vec3);
|
||||
let st = if grouped.is_some() && world_faces { s.world_state() } else { s.model.state() };
|
||||
let (base, n) = if let Some(point) = grouped {
|
||||
let (mesh, index, distance, name) = super::face::face_at_state(&st, point, str_(p, "face_body")).ok().flatten()?;
|
||||
if distance > (mesh.bounds().diagonal() * 1e-3).max(1e-3) * 10.0 {
|
||||
return None;
|
||||
}
|
||||
let normal = st.body(&name)?.body.planar_face(index as usize)?.normal();
|
||||
(point, normal)
|
||||
} else {
|
||||
extrude_base(s, p)?
|
||||
};
|
||||
let d = if grouped.is_some() && p.get("to").is_some() { (p.get("to").and_then(vec3)? - base).dot(n) } else { extrude_reach(s, p)? };
|
||||
if st.bodies.is_empty() || !d.is_finite() {
|
||||
return Some("new");
|
||||
}
|
||||
// Only the active component's bodies take part (or the ones named as participants).
|
||||
// Match the evaluator's implicit participants: a grouped cut from an empty
|
||||
// assembly component considers every body; otherwise keep the active scope.
|
||||
// Explicit participants override either default.
|
||||
let parts = string_list(p, "participants");
|
||||
let implicit_all = grouped.is_some() && !st.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component);
|
||||
let bodies: Vec<_> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| if parts.is_empty() { s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
|
||||
.filter(|b| if parts.is_empty() { implicit_all || s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
|
||||
.collect();
|
||||
if bodies.is_empty() {
|
||||
return Some("new");
|
||||
@@ -470,7 +489,8 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
// takes part in every body instead, which it changes only where it meets them.
|
||||
let participants = if participants.is_empty()
|
||||
&& !primitive
|
||||
&& matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
|
||||
&& (matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
|
||||
|| p.get("faces").is_some() && matches!(&kind, FeatureKind::Extrude { operation: Operation::Cut | Operation::Intersect, .. }))
|
||||
&& {
|
||||
let st = s.model.state();
|
||||
!st.bodies.is_empty() && !st.bodies.iter().any(|b| s.doc.model_body_component(b) == s.active_component)
|
||||
@@ -545,13 +565,31 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
|
||||
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.extrude";
|
||||
let grouped = if let Some(value) = p.get("faces") {
|
||||
if p.get("face").is_some() || p.get("sketch").is_some() || p.get("profiles").is_some() {
|
||||
return Err(bad(cmd, "faces cannot be combined with face, sketch or profiles"));
|
||||
}
|
||||
let faces = value
|
||||
.as_array()
|
||||
.filter(|faces| !faces.is_empty() && faces.len() <= 1024)
|
||||
.ok_or_else(|| bad(cmd, "faces must contain between 1 and 1024 points"))?;
|
||||
let points = faces.iter().map(|point| vec3(point).ok_or_else(|| bad(cmd, "faces must be points [x,y,z]"))).collect::<Result<Vec<_>>>()?;
|
||||
let (id, points, body) = super::face::sketch_of_faces(s, &points, cmd, str_(p, "face_body"))?;
|
||||
Some((id, Some(ProfileSel::Points { points }), Some(body)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
|
||||
let (sketch, face_profile, face_body) = match p.get("face").and_then(vec3) {
|
||||
Some(fp) => {
|
||||
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body))
|
||||
let (sketch, face_profile, face_body) = if let Some(grouped) = grouped {
|
||||
grouped
|
||||
} else {
|
||||
match p.get("face").and_then(vec3) {
|
||||
Some(fp) => {
|
||||
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body))
|
||||
}
|
||||
None => (feature_sketch(s, p, cmd)?, None, None),
|
||||
}
|
||||
None => (feature_sketch(s, p, cmd)?, None, None),
|
||||
};
|
||||
let extent = extent_param(s, p, cmd)?;
|
||||
let thin = match bool_(p, "thin") {
|
||||
@@ -565,7 +603,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => None,
|
||||
};
|
||||
let operation = match str_(p, "operation") {
|
||||
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation_in(s, p, false).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||
_ => operation(p, cmd)?,
|
||||
};
|
||||
let mut targets = string_list(p, "targets");
|
||||
|
||||
@@ -25,11 +25,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.rib", "Rib", rib)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("rib")
|
||||
.params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane); depth? (default: to the next face); flip?: bool (fill to the other side)"),
|
||||
.params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane, or across the curves when normal); depth? (default: to the next face); flip?: bool (fill to the other side); direction?: in_plane|normal (grow across the sketch plane, the default, or along its normal)"),
|
||||
CommandSpec::new("solid.web", "Web", web)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("web")
|
||||
.params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool"),
|
||||
.params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool; direction?: in_plane|normal"),
|
||||
CommandSpec::new("solid.emboss", "Emboss", emboss)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("emboss")
|
||||
@@ -133,7 +133,12 @@ fn rib_like(s: &mut Session, p: &Value, cmd: &str, web: bool) -> Result<Value> {
|
||||
check_expr(s, d, Kind::Length, cmd, "depth")?;
|
||||
}
|
||||
let flip = bool_(p, "flip").unwrap_or(false);
|
||||
add_feature(s, p, FeatureKind::Rib { sketch: SketchId(sketch), curves, thickness, depth, flip, web })
|
||||
let normal = match str_(p, "direction").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("in_plane" | "plane") => false,
|
||||
Some("normal") => true,
|
||||
Some(o) => return Err(bad(cmd, format!("unknown direction `{o}` (in_plane or normal)"))),
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Rib { sketch: SketchId(sketch), curves, thickness, depth, flip, web, normal })
|
||||
}
|
||||
|
||||
fn rib(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -2,6 +2,63 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn grouped_face_auto_operation_keeps_implicit_and_explicit_participants() {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"Part"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
for offset in [0., 30.] {
|
||||
if offset != 0. {
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":part["occurrence"],"translate":[offset,0,0]}));
|
||||
}
|
||||
// To Object follows the target's side, even when the distance and
|
||||
// direction fields still describe the opposite extrusion.
|
||||
for (height, expected) in [(8., "cut"), (12., "join")] {
|
||||
let params = json!({"faces":[[offset+2.,2,10],[offset+8.,8,10]],"face_body":"Stock",
|
||||
"to":[offset+5.,5,height],"direction":"negative"});
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some(expected));
|
||||
}
|
||||
for distance in [-2., 2.] {
|
||||
let mut params = json!({"faces":[[offset+2.,2,10],[offset+8.,8,10]],"distance":distance});
|
||||
let expected = if distance < 0. { "cut" } else { "join" };
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some(expected));
|
||||
params["face_body"] = json!("Stock");
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some(expected));
|
||||
let mut actual = s.scratch();
|
||||
if offset != 0. {
|
||||
run(&mut actual, "component.activate", json!({"component":part["component"]}));
|
||||
}
|
||||
params["operation"] = json!("auto");
|
||||
let made = run(&mut actual, "solid.extrude", params);
|
||||
let body = actual.model.state().body("Stock").unwrap().body.clone();
|
||||
let volume = solidcraft_kernel::measure(&body).unwrap().volume;
|
||||
assert!(
|
||||
(volume - (1000. + 100. * distance)).abs() < 1e-6,
|
||||
"offset={offset}, distance={distance}, volume={volume}, feature={:?}",
|
||||
actual.doc.feature(made["feature"].as_u64().unwrap())
|
||||
);
|
||||
}
|
||||
if offset != 0. {
|
||||
// Outside the edited component, grouping keeps the original explicit
|
||||
// face command's coordinate and participant behavior.
|
||||
let mut grouped = s.scratch();
|
||||
let mut single = s.scratch();
|
||||
run(&mut grouped, "solid.extrude", json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2,"operation":"cut"}));
|
||||
run(&mut single, "solid.extrude", json!({"face":[32,2,10],"face_body":"Stock","distance":-2,"operation":"cut"}));
|
||||
let volume = |session: &Session| solidcraft_kernel::measure(&session.model.state().body("Stock").unwrap().body).unwrap().volume;
|
||||
assert!((volume(&grouped) - volume(&single)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
run(&mut s, "solid.box", json!({"corner":[100,0,0],"length":10,"width":10,"height":10,"body_name":"Other"}));
|
||||
let mut params = json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2});
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some("join"), "an existing active-component body keeps its original participant scope");
|
||||
params["participants"] = json!(["Stock"]);
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some("cut"));
|
||||
params["participants"] = json!(["Other"]);
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some("join"));
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
@@ -28,6 +85,43 @@ fn volume(s: &mut Session) -> f64 {
|
||||
measure(s)["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separated_profile_cut_preserves_participants_and_removes_only_consumed_bodies() {
|
||||
for explicit in [false, true] {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 10), ("C", 30)] {
|
||||
run(&mut s, "solid.box", json!({"length":5,"width":5,"height":10,"corner":[x,0,0],"body_name":name}));
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Cutters"}));
|
||||
for (a, b) in [([-1, -1], [6, 6]), ([12, 2], [14, 4]), ([32, 2], [34, 4])] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":a,"p1":b}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = s.doc.to_json();
|
||||
let mut params = json!({"sketch":"Cutters","profiles":"all","distance":10,"operation":"cut"});
|
||||
if explicit {
|
||||
params["participants"] = json!(["A", "B"]);
|
||||
}
|
||||
run(&mut s, "solid.extrude", params);
|
||||
let check = |session: &Session| {
|
||||
let state = session.model.state();
|
||||
assert_eq!(state.bodies.len(), 2);
|
||||
assert!(state.body("A").is_none());
|
||||
for (name, expected) in [("B", 210.), ("C", if explicit { 250. } else { 210. })] {
|
||||
let body = &state.body(name).unwrap().body;
|
||||
assert!((solidcraft_kernel::measure(body).unwrap().volume - expected).abs() < 1e-6);
|
||||
assert!(body.validity().is_empty());
|
||||
}
|
||||
};
|
||||
check(&s);
|
||||
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
@@ -77,6 +171,28 @@ fn rib_with_a_depth_and_web() {
|
||||
assert!(e.is_err_and(|e| e.to_string().contains("do not face a body")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rib_along_the_sketch_normal_reaches_the_body() {
|
||||
// A 20 x 20 x 10 box; a line along X on a sketch 10 in front of it (y = -10), whose plane
|
||||
// the box does not cross, so only a rib along the sketch normal can reach it.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, -10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Front"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[2, 5], [18, 5]], "ids": ["l1"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let e = s.execute("solid.rib", &json!({"sketch": "Front", "curves": ["l1"], "thickness": 2}));
|
||||
assert!(e.is_err_and(|e| e.to_string().contains("do not face a body")));
|
||||
run(&mut s, "solid.rib", json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "normal"}));
|
||||
// 16 long, 2 thick, 10 from the sketch to the box.
|
||||
assert!(rel(volume(&mut s), 4000.0 + 320.0) < 1e-6, "{}", volume(&mut s));
|
||||
assert_eq!(measure(&mut s)["body_count"], 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "solid.rib", json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "normal", "depth": 4}));
|
||||
assert!(rel(volume(&mut s), 4000.0 + 128.0) < 1e-6, "{}", volume(&mut s));
|
||||
let e = s.execute("solid.rib", &json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "sideways"}));
|
||||
assert!(e.is_err_and(|e| e.to_string().contains("unknown direction")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emboss_and_deboss_on_a_face() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -69,6 +69,7 @@ mod sketch_canvas;
|
||||
pub mod sketch_clipboard;
|
||||
mod sketch_constraints;
|
||||
mod sketch_create;
|
||||
mod sketch_ends;
|
||||
mod sketch_freeform;
|
||||
mod sketch_import;
|
||||
mod sketch_inspect;
|
||||
@@ -236,6 +237,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(sketch_constraints::COMMANDS.iter());
|
||||
v.extend(sketch_import::COMMANDS.iter());
|
||||
v.extend(sketch_inspect::COMMANDS.iter());
|
||||
v.extend(sketch_ends::COMMANDS.iter());
|
||||
v.extend(sketch_canvas::COMMANDS.iter());
|
||||
v.extend(sketch_3d::COMMANDS.iter());
|
||||
v.extend(surface_analysis::COMMANDS.iter());
|
||||
|
||||
@@ -12,7 +12,7 @@ const CMD: &str = "inspect.silhouette";
|
||||
const MAX_PIXELS: usize = 4 * 1024 * 1024;
|
||||
|
||||
// For agents comparing the model with a picture; nothing to show a person, so no toolbar button.
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", run).noundo().params(
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", run).icon("project").noundo().params(
|
||||
"view?: front|back|left|right|top|bottom|iso (default front); or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (mm, radians; fov 0 means parallel); bodies?: [world body names] (default visible bodies, named hidden bodies included); rows?: mask height in pixels (default 240, clamped to 16..1024) → tightly framed run-length mask, box_mm, mm_per_px, right/up, projection, body and triangle counts",
|
||||
)];
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Loose ends of a sketch's profile curves: the ones that touch nothing (open) and the ones that
|
||||
//! touch another curve only by position (untied), and tying the untied ones.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_sketch::{ConstraintKind, EndReport, EndTouch, Sketch, loose_ends};
|
||||
|
||||
use super::sketch::{add_c, edit, target_sketch};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, string_list};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.loose_ends", "Loose Ends", loose_ends_cmd).icon("c_coincident").enabled(in_sketch).noundo().params(
|
||||
"sketch?: id|name (default active): the ends of profile curves (not construction, projected or text) that can make a profile fail. open: [{point, curve, at}] ends that touch no other profile curve (no profile runs through them); untied: [{point, curve, at, touches: {point}|{curve}}] ends that touch another curve only by position (the profile closes now, but nothing keeps it closed when a dimension changes)",
|
||||
),
|
||||
CommandSpec::new("sketch.loose_ends.tie", "Tie Loose Ends", tie_cmd).icon("c_coincident").enabled(in_sketch).params(
|
||||
"sketch?: id|name (default active); points?: [point ids] (default: every untied end): ties each untied end to what it touches, coincident with another curve's end or on another curve. Returns constraints: [ids added], and the untied ends left",
|
||||
),
|
||||
];
|
||||
|
||||
fn point_id(sk: &Sketch, i: usize) -> Value {
|
||||
sk.points.get(i).map_or(Value::Null, |p| json!(p.id))
|
||||
}
|
||||
|
||||
fn curve_id(sk: &Sketch, i: usize) -> Value {
|
||||
sk.curves.get(i).map_or(Value::Null, |c| json!(c.id))
|
||||
}
|
||||
|
||||
fn at(sk: &Sketch, i: usize) -> Value {
|
||||
sk.point(i).map_or(Value::Null, |v| json!([v.x, v.y]))
|
||||
}
|
||||
|
||||
/// The point that is an end of curve `c` (for `curve` in the report).
|
||||
fn end_curve(sk: &Sketch, p: usize) -> Value {
|
||||
let c = sk.curves.iter().position(|c| c.kind.ends().is_some_and(|(a, b)| a == p || b == p) && !(c.construction || c.centerline || c.infinite));
|
||||
c.map_or(Value::Null, |c| curve_id(sk, c))
|
||||
}
|
||||
|
||||
fn report_json(sk: &Sketch, r: &EndReport) -> Value {
|
||||
let open: Vec<Value> = r.open.iter().map(|&p| json!({"point": point_id(sk, p), "curve": end_curve(sk, p), "at": at(sk, p)})).collect();
|
||||
let untied: Vec<Value> = r
|
||||
.untied
|
||||
.iter()
|
||||
.map(|u| {
|
||||
let touches = match u.touches {
|
||||
EndTouch::Point(q) => json!({"point": point_id(sk, q)}),
|
||||
EndTouch::Curve(c) => json!({"curve": curve_id(sk, c)}),
|
||||
};
|
||||
json!({"point": point_id(sk, u.point), "curve": curve_id(sk, u.curve), "at": at(sk, u.point), "touches": touches})
|
||||
})
|
||||
.collect();
|
||||
json!({"open": open, "untied": untied})
|
||||
}
|
||||
|
||||
fn loose_ends_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target_sketch(s, p, "sketch.loose_ends")?;
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch has not been evaluated".into()))?;
|
||||
Ok(report_json(&ss.sketch, &ss.ends))
|
||||
}
|
||||
|
||||
fn tie_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.loose_ends.tie";
|
||||
let only = match p.get("points") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) if a.iter().all(Value::is_string) => Some(string_list(p, "points")),
|
||||
Some(_) => return Err(bad(cmd, "`points` must be a list of point ids")),
|
||||
};
|
||||
let (added, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let wanted = match &only {
|
||||
None => None,
|
||||
Some(ids) => Some(
|
||||
ids.iter().map(|r| sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown point `{r}`")))).collect::<Result<Vec<usize>>>()?,
|
||||
),
|
||||
};
|
||||
let report = loose_ends(sk);
|
||||
let mut added = Vec::new();
|
||||
for u in &report.untied {
|
||||
if wanted.as_ref().is_some_and(|w| !w.contains(&u.point)) {
|
||||
continue;
|
||||
}
|
||||
let kind = match u.touches {
|
||||
EndTouch::Point(q) => ConstraintKind::Coincident { p: u.point, q },
|
||||
EndTouch::Curve(c) => ConstraintKind::PointOnCurve { p: u.point, c },
|
||||
};
|
||||
added.push(add_c(sk, kind)?);
|
||||
}
|
||||
let left = report_json(sk, &loose_ends(sk)).get("untied").cloned().unwrap_or(Value::Null);
|
||||
Ok((added, left))
|
||||
})?;
|
||||
let (added, left) = added;
|
||||
Ok(json!({"constraints": added, "sketch": info, "untied": left}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_ends_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,94 @@
|
||||
//! `sketch.loose_ends` and `sketch.loose_ends.tie`: ends that touch nothing or only touch by
|
||||
//! position, and tying them so the profile stays closed when the sketch changes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A square of four separate lines: closed by position only.
|
||||
fn loose_square(s: &mut Session) -> Vec<String> {
|
||||
run(s, "sketch.create", json!({"plane": "XY"}));
|
||||
let c = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
(0..4)
|
||||
.map(|i| {
|
||||
let r = run(s, "sketch.line", json!({"points": [c[i], c[(i + 1) % 4]], "infer": false, "connect": false}));
|
||||
r["curves"][0].as_str().unwrap().to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_square_closed_by_position_has_four_untied_corners() {
|
||||
let mut s = Session::default();
|
||||
loose_square(&mut s);
|
||||
assert_eq!(run(&mut s, "sketch.solve", json!({}))["profiles"], 1);
|
||||
let r = run(&mut s, "sketch.loose_ends", json!({}));
|
||||
assert_eq!(r["open"].as_array().unwrap().len(), 0, "{r}");
|
||||
let untied = r["untied"].as_array().unwrap();
|
||||
assert_eq!(untied.len(), 4, "{r}");
|
||||
assert!(untied.iter().all(|u| u["touches"]["point"].is_string() && u["at"].is_array()), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tying_keeps_the_profile_closed_when_a_side_moves() {
|
||||
let mut s = Session::default();
|
||||
let lines = loose_square(&mut s);
|
||||
let r = run(&mut s, "sketch.loose_ends.tie", json!({}));
|
||||
assert_eq!(r["constraints"].as_array().unwrap().len(), 4, "{r}");
|
||||
assert_eq!(r["untied"].as_array().unwrap().len(), 0, "{r}");
|
||||
assert!(run(&mut s, "sketch.loose_ends", json!({}))["untied"].as_array().unwrap().is_empty());
|
||||
// Lengthen the bottom side: the corners follow it and the square stays one profile.
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [lines[0]], "type": "length", "value": 14}));
|
||||
assert_eq!(run(&mut s, "sketch.solve", json!({}))["profiles"], 1);
|
||||
// Tying is one undo step.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(run(&mut s, "sketch.loose_ends", json!({}))["untied"].as_array().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tie_takes_only_the_points_asked_for() {
|
||||
let mut s = Session::default();
|
||||
loose_square(&mut s);
|
||||
let r = run(&mut s, "sketch.loose_ends", json!({}));
|
||||
let first = r["untied"][0]["point"].clone();
|
||||
let t = run(&mut s, "sketch.loose_ends.tie", json!({"points": [first]}));
|
||||
assert_eq!(t["constraints"].as_array().unwrap().len(), 1, "{t}");
|
||||
assert_eq!(t["untied"].as_array().unwrap().len(), 3, "{t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_shows_two_open_ends() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [10, 10], [0, 9.9]], "infer": false}));
|
||||
let r = run(&mut s, "sketch.loose_ends", json!({}));
|
||||
assert_eq!(r["open"].as_array().unwrap().len(), 2, "{r}");
|
||||
assert!(r["untied"].as_array().unwrap().is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_tie_params_are_errors() {
|
||||
let mut s = Session::default();
|
||||
loose_square(&mut s);
|
||||
for p in [json!({"points": 3}), json!({"points": ["nope"]}), json!({"points": [null, 1e308]}), json!({"sketch": "missing"})] {
|
||||
assert!(s.execute("sketch.loose_ends.tie", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute("sketch.loose_ends", &json!({"sketch": "missing"})).is_err());
|
||||
// Nothing changed.
|
||||
assert_eq!(run(&mut s, "sketch.loose_ends", json!({}))["untied"].as_array().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_a_sketch_they_are_refused() {
|
||||
let mut s = Session::default();
|
||||
assert!(s.execute("sketch.loose_ends", &json!({})).is_err());
|
||||
assert!(s.execute("sketch.loose_ends.tie", &json!({})).is_err());
|
||||
}
|
||||
@@ -520,21 +520,34 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
|
||||
/// Private compatibility projection for generated face profiles. Named legacy links follow
|
||||
/// edits, but cannot provide the construction certificate required by public attachments.
|
||||
pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> {
|
||||
project_internal_faces(s, id, body, &[index])?.pop().ok_or_else(|| bad("face profile", "empty face projection"))
|
||||
}
|
||||
|
||||
/// Capture individual replay names together, against the same unchanged source body.
|
||||
pub(super) fn project_internal_faces(s: &mut Session, id: u64, body: &str, indices: &[usize]) -> Result<Vec<String>> {
|
||||
if indices.len() > 1024 {
|
||||
return Err(bad("face profile", "too many faces"));
|
||||
}
|
||||
let mut doc = (*s.doc).clone();
|
||||
let raw = s.model.state_before(id);
|
||||
let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0);
|
||||
let state = solidcraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component));
|
||||
let plane = sketch_plane(&doc, id, &state)?;
|
||||
let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
|
||||
let face_name = state.body(body).and_then(|body| solidcraft_doc::naming::face_names(body).get(index).cloned());
|
||||
let names = state.body(body).map(solidcraft_doc::naming::face_names).unwrap_or_default();
|
||||
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?;
|
||||
if let Some(entry) = sk.links.iter_mut().find(|entry| entry.id == link) {
|
||||
entry.face_name = face_name;
|
||||
let mut links = Vec::with_capacity(indices.len());
|
||||
for &index in indices {
|
||||
let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
|
||||
let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?;
|
||||
if let Some(entry) = sk.links.iter_mut().find(|entry| entry.id == link) {
|
||||
entry.face_name = names.get(index).cloned();
|
||||
}
|
||||
links.push(link);
|
||||
}
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok(link)
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
/// Project the edges of the face a new sketch was created on (Fusion's default).
|
||||
|
||||
@@ -1,38 +1,56 @@
|
||||
//! Boundary surfaces: one patch through a network of two to four sketch curves (`surface.boundary`,
|
||||
//! issue #763). The kernel builds the face from the curves' own frames; this is the command's
|
||||
//! parameters and its checks, so that a loop it cannot hold is refused before anything is built.
|
||||
//! Boundary surfaces (`surface.boundary`, issue #763): one patch through two to four sketch
|
||||
//! curves in loop order, or one surface through a network of curves in two directions (sketch
|
||||
//! curves, 3D sketch curves, a body's edges, or a point for a three-sided patch). The kernel
|
||||
//! builds the face; this is the command's parameters and its checks, so that what it cannot hold
|
||||
//! is refused before anything is built.
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{FeatureKind, SketchId};
|
||||
use solidcraft_doc::{BodyName, FeatureKind, NetworkCurve, Operation, Position, SketchId};
|
||||
use solidcraft_kernel::Continuity;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, feature_sketch};
|
||||
use crate::params::{bad, string_list};
|
||||
use super::features::{add_feature, feature_sketch, sketch_id};
|
||||
use crate::params::{bad, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The stations a boundary carries by default (the patch's resolution along the loop).
|
||||
const DEFAULT_SAMPLES: usize = 25;
|
||||
const DEFAULT_SAMPLES: usize = solidcraft_doc::BOUNDARY_SAMPLES;
|
||||
/// The fewest stations a patch can hold the widest continuity pair with: a G3 side needs four rows
|
||||
/// at each of two opposite sides, so eight.
|
||||
const MIN_SAMPLES: usize = 8;
|
||||
/// The most stations a boundary may carry (the kernel's own cap).
|
||||
const MAX_SAMPLES: usize = 512;
|
||||
/// The most curves one direction of a network may list (the kernel's own cap).
|
||||
const MAX_NETWORK_CURVES: usize = solidcraft_geom::network::MAX_NETWORK_CURVES;
|
||||
/// The most sketch curves one network curve may chain, and points picking one chain of edges
|
||||
/// (the kernel's own caps).
|
||||
use solidcraft_kernel::{MAX_CHAIN_CURVES as MAX_CHAIN, MAX_EDGE_PICKS as MAX_PICKS};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("surface.boundary", "Boundary Surface", boundary)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_boundary")
|
||||
.params(
|
||||
"sketch?: id|name (default: the sketch being edited); curves: [2 to 4 sketch curve ids in loop order] (each one ends where the next begins and the last where the first does; the patch runs through them and leaves each boundary along the tangent across it); continuity?: g0|g1|g2|g3 | [one per curve] (default g1: how much of the surface across each boundary the patch repeats — a sketch curve's own plane, so g1 and up hold exactly and g0 lets the middle bow); crown?: number -0.9…3 (default 0: how full the middle is, without touching a boundary); samples?: 8…512 stations per boundary (default 25); name?, body_name? (a surface body)",
|
||||
"Either a loop of one sketch's curves — sketch?: id|name (default: the sketch being edited); curves: [2 to 4 sketch curve ids in loop order] (each one ends where the next begins and the last where the first does; two: the patch spans between them and leaves each boundary along the tangent across it; three or four: the flat patch of the loop, three collapsing the side opposite the first curve to the corner there); continuity?: g0|g1|g2|g3 | [one per curve] (default g1: how much of the surface across each boundary the patch repeats — a sketch curve's own plane, so g1 and up hold exactly and g0 lets the middle bow); crown?: number -0.9…3 (default 0: how full the middle is, without touching a boundary; two curves only); samples?: 8…512 stations per boundary (default 25) — or a curve network — u: [curve] (two or more one way: the surface lofts through them), v?: [curve] (crossing every u-curve once: two each way give a Coons patch, more a Gordon surface through every curve; a curve that misses another by more than 0.01 mm is refused, naming the pair), each curve \"id\" (a curve of `sketch`) | {sketch?: id|name, curves: [ids meeting end to end]} (planar or 3D sketch curves) | {body: name, edges: [[x,y,z]] (points on a chain of its edges), continuity?: g0|g1|g2 (default g0; g1 the surface leaves tangent to the face beside a surface's open edges, g2 with its curvature too; on curves of one direction only)} | {point: [x,y,z]} (a side collapsed to a point: a three-sided patch); operation?: new|join (default new; join sews the surface into the body of its first edge curve, a solid when it closes the gap). name?, body_name? (a surface body)",
|
||||
)];
|
||||
|
||||
/// `surface.boundary`: one patch through a network of two to four sketch curves.
|
||||
/// `surface.boundary`: a loop of sketch curves, or a network when `u` is given.
|
||||
fn boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("u").is_some_and(|v| !v.is_null()) {
|
||||
return network(s, p);
|
||||
}
|
||||
loop_patch(s, p)
|
||||
}
|
||||
|
||||
/// `surface.boundary` with `curves`: one patch through two to four sketch curves in loop order.
|
||||
fn loop_patch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.boundary";
|
||||
if p.get("v").is_some_and(|v| !v.is_null()) || p.get("operation").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(cmd, "`v` and `operation` belong to a curve network: give its curves as `u` and `v`"));
|
||||
}
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let curves = string_list(p, "curves");
|
||||
if !(2..=4).contains(&curves.len()) {
|
||||
return Err(bad(cmd, "`curves` must list 2 to 4 sketch curves in loop order"));
|
||||
return Err(bad(cmd, "`curves` must list 2 to 4 sketch curves in loop order (or give a network as `u` and `v`)"));
|
||||
}
|
||||
// The curves must be the sketch's own, and they must run on into one another: the last station
|
||||
// of each is the first of the next, and the last boundary's end is the first one's start.
|
||||
@@ -98,9 +116,134 @@ fn boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(cmd, format!("`samples` must be a whole number of stations between {MIN_SAMPLES} and {MAX_SAMPLES}")))?
|
||||
}
|
||||
};
|
||||
if crown != 0.0 && curves.len() >= 3 {
|
||||
return Err(bad(cmd, "not supported yet: `crown` on a patch through three or four curves (it is the flat patch of its sides)"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::SurfaceBoundary { sketch: SketchId(sketch), curves, orders, crown, samples })
|
||||
}
|
||||
|
||||
/// `surface.boundary` with `u` (and `v`): one surface through a curve network.
|
||||
fn network(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.boundary";
|
||||
for key in ["curves", "continuity", "crown", "samples"] {
|
||||
if p.get(key).is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(cmd, format!("`{key}` belongs to a loop of sketch curves: a network gives each curve its own continuity")));
|
||||
}
|
||||
}
|
||||
let operation = super::features::operation(p, cmd)?;
|
||||
if !matches!(operation, Operation::NewBody | Operation::Join) {
|
||||
return Err(bad(cmd, "`operation` must be new or join"));
|
||||
}
|
||||
// The sketch a bare curve id belongs to, found only when one is used.
|
||||
let mut default_sketch: Option<u64> = None;
|
||||
let u = network_curves(s, p, "u", &mut default_sketch)?;
|
||||
let v = network_curves(s, p, "v", &mut default_sketch)?;
|
||||
if u.is_empty() {
|
||||
return Err(bad(cmd, "`u` must list the curves of one direction"));
|
||||
}
|
||||
if u.len() + v.len() < 2 {
|
||||
return Err(bad(cmd, "a boundary surface needs two curves in one direction, or crossing curves"));
|
||||
}
|
||||
if operation == Operation::Join && !u.iter().chain(&v).any(|c| c.body.is_some()) {
|
||||
return Err(bad(cmd, "`operation` join needs a curve on a body's edges: the surface is sewn into that body"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::SurfaceNetwork { u, v, operation })
|
||||
}
|
||||
|
||||
/// One direction of a network (`key` is `u` or `v`), checked: each curve sketch curves that exist
|
||||
/// (planar or 3D), a body's edges with points on them, or a point; a continuity only on edges.
|
||||
fn network_curves(s: &Session, p: &Value, key: &str, default_sketch: &mut Option<u64>) -> Result<Vec<NetworkCurve>> {
|
||||
let cmd = "surface.boundary";
|
||||
let list = match p.get(key) {
|
||||
None | Some(Value::Null) => return Ok(Vec::new()),
|
||||
Some(Value::Array(a)) => a,
|
||||
Some(_) => return Err(bad(cmd, format!("`{key}` must list curves"))),
|
||||
};
|
||||
if list.len() > MAX_NETWORK_CURVES {
|
||||
return Err(bad(cmd, format!("`{key}` may list at most {MAX_NETWORK_CURVES} curves")));
|
||||
}
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for (k, item) in list.iter().enumerate() {
|
||||
let name = format!("{key}[{k}]");
|
||||
let mut curve = NetworkCurve { sketch: None, curves: Vec::new(), body: None, edges: Vec::new(), point: None, continuity: Continuity::G0 };
|
||||
let obj = match item {
|
||||
Value::String(id) => {
|
||||
curve.curves = vec![id.clone()];
|
||||
None
|
||||
}
|
||||
Value::Object(_) => Some(item),
|
||||
_ => return Err(bad(cmd, format!("{name} must be a curve id or {{sketch?, curves}}, {{body, edges}} or {{point}}"))),
|
||||
};
|
||||
if let Some(o) = obj {
|
||||
let has = |k: &str| o.get(k).is_some_and(|v| !v.is_null());
|
||||
let kinds = [has("curves"), has("body") || has("edges"), has("point")];
|
||||
if kinds.iter().filter(|x| **x).count() != 1 {
|
||||
return Err(bad(cmd, format!("{name} must be one of {{sketch?, curves}}, {{body, edges}} or {{point}}")));
|
||||
}
|
||||
if has("point") {
|
||||
curve.point = Some(Position(o.get("point").and_then(vec3).ok_or_else(|| bad(cmd, format!("{name}.point must be [x, y, z]")))?));
|
||||
} else if kinds[1] {
|
||||
let body = str_(o, "body").ok_or_else(|| bad(cmd, format!("{name}.body must name a body")))?;
|
||||
if s.model.state().body(body).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
let edges =
|
||||
o.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("{name}.edges must list points on the edges")))?;
|
||||
if edges.is_empty() || edges.len() > MAX_PICKS {
|
||||
return Err(bad(cmd, format!("{name}.edges must list 1 to {MAX_PICKS} points on a chain of edges")));
|
||||
}
|
||||
let pts: Vec<Position> = edges
|
||||
.iter()
|
||||
.map(|e| vec3(e).map(Position).ok_or_else(|| bad(cmd, format!("{name}.edges: a point must be [x, y, z]"))))
|
||||
.collect::<Result<_>>()?;
|
||||
curve.body = Some(BodyName::from(body.to_string()));
|
||||
curve.edges = pts;
|
||||
} else {
|
||||
curve.curves = string_list(o, "curves");
|
||||
if o.get("sketch").is_some_and(|v| !v.is_null()) {
|
||||
curve.sketch = Some(SketchId(sketch_id(s, o.get("sketch"), cmd, &format!("{name}.sketch"))?));
|
||||
}
|
||||
}
|
||||
if let Some(g) = o.get("continuity").filter(|v| !v.is_null()) {
|
||||
let g = g.as_str().and_then(Continuity::parse).ok_or_else(|| bad(cmd, format!("{name}.continuity must be g0, g1 or g2")))?;
|
||||
if g == Continuity::G3 {
|
||||
return Err(bad(cmd, format!("not supported yet: G3 on {name} (a curve network holds up to g2)")));
|
||||
}
|
||||
if g != Continuity::G0 && curve.body.is_none() {
|
||||
return Err(bad(cmd, format!("{name}: g1 and g2 apply to a curve on a body's edges (a surface's open edges)")));
|
||||
}
|
||||
curve.continuity = g;
|
||||
}
|
||||
}
|
||||
if curve.point.is_none() && curve.body.is_none() {
|
||||
if curve.curves.is_empty() || curve.curves.len() > MAX_CHAIN {
|
||||
return Err(bad(cmd, format!("{name} must list 1 to {MAX_CHAIN} sketch curves")));
|
||||
}
|
||||
let sketch = match curve.sketch {
|
||||
Some(id) => id.0,
|
||||
None => match *default_sketch {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = feature_sketch(s, p, cmd)?;
|
||||
*default_sketch = Some(id);
|
||||
id
|
||||
}
|
||||
},
|
||||
};
|
||||
let state = s.model.state();
|
||||
let sk = state.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch has not been solved"))?;
|
||||
for id in &curve.curves {
|
||||
if sk.sketch.curve_index(id).is_none() && sk.sketch.wire_index(id).is_none() {
|
||||
return Err(bad(cmd, format!("{name}: no sketch curve `{id}`")));
|
||||
}
|
||||
}
|
||||
curve.sketch = Some(SketchId(sketch));
|
||||
}
|
||||
out.push(curve);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Value, json};
|
||||
@@ -154,27 +297,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boundary_patch_refuses_a_loop_and_values_it_cannot_hold() {
|
||||
fn a_boundary_patch_refuses_values_it_cannot_hold() {
|
||||
let mut s = new_sketch();
|
||||
let curves = two_walls(&mut s);
|
||||
let extra = ids(&run(&mut s, "sketch.line", json!({"points": [[30, 30], [40, 40]]}))["created"]["curves"])[0].clone();
|
||||
// A loop of three or four: the kernel refuses it with its own reason (a tensor-product patch
|
||||
// cannot hold two sides' data at a corner, issue #763) rather than building it approximately.
|
||||
let triangle = ["l1", "l2", "l3"].map(|id| {
|
||||
let _ = id;
|
||||
String::new()
|
||||
});
|
||||
let _ = triangle;
|
||||
// A loop of three: the crown is the bridge's, not the flat loop patch's.
|
||||
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["created"]["curves"])[0].clone();
|
||||
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 0], [10, 15]]}))["created"]["curves"])[0].clone();
|
||||
let c = ids(&run(&mut s, "sketch.line", json!({"points": [[10, 15], [0, 0]]}))["created"]["curves"])[0].clone();
|
||||
let e = s.execute("surface.boundary", &json!({"curves": [a, b, c], "continuity": "g2"})).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet") && e.contains("#763"), "three boundaries: {e}");
|
||||
// A closed square of four lines, in loop order.
|
||||
let sq = [[[0, 0], [10, 0]], [[10, 0], [10, 10]], [[10, 10], [0, 10]], [[0, 10], [0, 0]]];
|
||||
let square: Vec<String> = sq.iter().map(|p| ids(&run(&mut s, "sketch.line", json!({"points": p}))["created"]["curves"])[0].clone()).collect();
|
||||
let e = s.execute("surface.boundary", &json!({"curves": square})).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet") && e.contains("#763"), "four boundaries: {e}");
|
||||
let e = s.execute("surface.boundary", &json!({"curves": [a, b, c], "crown": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet") && e.contains("crown"), "three boundaries with a crown: {e}");
|
||||
// Curves out of loop order.
|
||||
let e = s.execute("surface.boundary", &json!({"curves": [curves[0], curves[1], extra]})).unwrap_err().to_string();
|
||||
assert!(e.contains("loop order"), "{e}");
|
||||
// Parameters and curves that make no sense.
|
||||
let cases: Vec<(Value, &str)> = vec![
|
||||
(json!({"curves": [curves[0]]}), "2 to 4"),
|
||||
@@ -186,6 +321,7 @@ mod tests {
|
||||
(json!({"curves": curves, "crown": "full"}), "crown"),
|
||||
(json!({"curves": curves, "samples": 3}), "samples"),
|
||||
(json!({"curves": curves, "samples": 2.5}), "samples"),
|
||||
(json!({"curves": curves, "operation": "join"}), "network"),
|
||||
];
|
||||
for (params, want) in cases {
|
||||
let e = s.execute("surface.boundary", ¶ms).unwrap_err().to_string();
|
||||
@@ -195,3 +331,7 @@ mod tests {
|
||||
assert!(s.model.state().bodies.is_empty(), "a refused patch left a body behind");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "surface_boundary_tests.rs"]
|
||||
mod network_tests;
|
||||
@@ -0,0 +1,267 @@
|
||||
//! `surface.boundary` through loops of three or four sketch curves and through curve networks
|
||||
//! (#763): analytic areas and shapes, G1 and G2 measured across the edge a surface shares with a
|
||||
//! body (`inspect.continuity`), and refusals of hostile networks that never panic.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn first(v: &Value) -> String {
|
||||
v.as_array().and_then(|a| a.first()).and_then(Value::as_str).map(str::to_string).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A sketch line's id.
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
|
||||
first(&run(s, "sketch.line", json!({"points": [a, b]}))["created"]["curves"])
|
||||
}
|
||||
|
||||
/// A 3D sketch line's id (the active sketch must have 3D Sketch on).
|
||||
fn line3d(s: &mut Session, a: [f64; 3], b: [f64; 3]) -> String {
|
||||
first(&run(s, "sketch.line3d", json!({"points": [a, b]}))["curves"])
|
||||
}
|
||||
|
||||
/// The area of the one face of `body`, and the area `inspect.measure` gives for the whole model.
|
||||
fn areas(s: &mut Session, body: &str) -> (f64, f64) {
|
||||
let f = run(s, "model.faces", json!({"body": body}));
|
||||
let faces = f["faces"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(faces.len(), 1, "one face: {f}");
|
||||
let face = faces[0]["area"].as_f64().unwrap_or(f64::NAN);
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
(face, m["total"]["area_mm2"].as_f64().unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
/// Three and four sketch curves in loop order: the flat patch of the loop. A triangle and a square
|
||||
/// of lines, and a square whose top is a half circle bulging out, each measure their own area.
|
||||
#[test]
|
||||
fn three_and_four_curve_loops_are_flat_patches() {
|
||||
use std::f64::consts::PI;
|
||||
let cases: Vec<(Vec<[[f64; 2]; 2]>, f64, f64)> = vec![
|
||||
(vec![[[0.0, 0.0], [20.0, 0.0]], [[20.0, 0.0], [10.0, 15.0]], [[10.0, 15.0], [0.0, 0.0]]], 150.0, 1e-6),
|
||||
(vec![[[0.0, 0.0], [10.0, 0.0]], [[10.0, 0.0], [10.0, 10.0]], [[10.0, 10.0], [0.0, 10.0]], [[0.0, 10.0], [0.0, 0.0]]], 100.0, 1e-6),
|
||||
];
|
||||
for (lines, want, tol) in cases {
|
||||
for continuity in [json!("g0"), json!("g1"), json!("g3")] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let curves: Vec<String> = lines.iter().map(|[a, b]| line(&mut s, *a, *b)).collect();
|
||||
let r = run(&mut s, "surface.boundary", json!({"curves": curves, "continuity": continuity}));
|
||||
let body = first(&r["created_bodies"]);
|
||||
let (face, total) = areas(&mut s, &body);
|
||||
assert!(
|
||||
(face - want).abs() < tol * want && (total - want).abs() < tol * want,
|
||||
"{} sides, {continuity}: {face} / {total} vs {want}",
|
||||
lines.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
// A square with a half circle for its top, bulging out: 100 + 12.5π.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let bottom = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let right = line(&mut s, [10.0, 0.0], [10.0, 10.0]);
|
||||
let arc = first(&run(&mut s, "sketch.arc.three_point", json!({"start": [10, 10], "end": [0, 10], "through": [5, 15]}))["created"]["curves"]);
|
||||
let left = line(&mut s, [0.0, 10.0], [0.0, 0.0]);
|
||||
let r = run(&mut s, "surface.boundary", json!({"curves": [bottom, right, arc, left]}));
|
||||
let (face, total) = areas(&mut s, &first(&r["created_bodies"]));
|
||||
let want = 100.0 + 12.5 * PI;
|
||||
assert!((face - want).abs() < 2e-3 * want && (total - want).abs() < 2e-3 * want, "{face} / {total} vs {want}");
|
||||
}
|
||||
|
||||
/// Simpson's rule over [0, a] × [0, a] with n (even) intervals each way.
|
||||
fn simpson2(f: impl Fn(f64, f64) -> f64, a: f64, n: usize) -> f64 {
|
||||
let h = a / n as f64;
|
||||
let w = |k: usize| {
|
||||
if k == 0 || k == n {
|
||||
1.0
|
||||
} else if k % 2 == 1 {
|
||||
4.0
|
||||
} else {
|
||||
2.0
|
||||
}
|
||||
};
|
||||
let mut sum = 0.0;
|
||||
for i in 0..=n {
|
||||
for j in 0..=n {
|
||||
sum += w(i) * w(j) * f(i as f64 * h, j as f64 * h);
|
||||
}
|
||||
}
|
||||
sum * h * h / 9.0
|
||||
}
|
||||
|
||||
/// Four straight 3D sketch lines bounding the hyperbolic paraboloid z = x·y/10 over the 10 × 10
|
||||
/// square: the network's Coons patch is that surface (every vertex of its mesh on it), and its
|
||||
/// area is the paraboloid's, ∫∫ √(1 + (x/10)² + (y/10)²).
|
||||
#[test]
|
||||
fn a_network_of_3d_lines_is_a_hyperbolic_paraboloid() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
|
||||
let a = line3d(&mut s, [0.0, 0.0, 0.0], [10.0, 0.0, 0.0]);
|
||||
let c = line3d(&mut s, [0.0, 10.0, 0.0], [10.0, 10.0, 10.0]);
|
||||
let d = line3d(&mut s, [0.0, 0.0, 0.0], [0.0, 10.0, 0.0]);
|
||||
let b = line3d(&mut s, [10.0, 0.0, 0.0], [10.0, 10.0, 10.0]);
|
||||
let r = run(&mut s, "surface.boundary", json!({"u": [a, c], "v": [d, b]}));
|
||||
let body = first(&r["created_bodies"]);
|
||||
let want = simpson2(|x, y| (1.0 + (x / 10.0).powi(2) + (y / 10.0).powi(2)).sqrt(), 10.0, 200);
|
||||
let (face, total) = areas(&mut s, &body);
|
||||
// The face's own area comes from the coarse reference mesh (a chord of a fiftieth of a per
|
||||
// cent of the body); the measuring mesh is refined onto the surface.
|
||||
assert!((face - want).abs() < 1e-2 * want && (total - want).abs() < 1e-3 * want, "{face} / {total} vs {want}");
|
||||
let st = s.world_state();
|
||||
let mesh = st.body(&body).map(|b| b.body.tessellate(0.01)).and_then(Result::ok).unwrap_or_default();
|
||||
assert!(!mesh.positions.is_empty());
|
||||
for p in &mesh.positions {
|
||||
assert!((p.z - p.x * p.y / 10.0).abs() < 1e-6, "{p:?} is off the paraboloid");
|
||||
}
|
||||
}
|
||||
|
||||
/// Curves of two planar sketches, one picked by a bare id in the sketch being edited: the loft
|
||||
/// between a line on the ground and one 10 mm across and 5 mm up is the flat strip between them.
|
||||
#[test]
|
||||
fn a_network_takes_curves_from_several_sketches() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Low"}));
|
||||
let low = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 5, "name": "High"}));
|
||||
let high = line(&mut s, [0.0, 10.0], [10.0, 10.0]);
|
||||
let r = run(&mut s, "surface.boundary", json!({"u": [{"sketch": "Low", "curves": [low]}, high]}));
|
||||
let (face, _) = areas(&mut s, &first(&r["created_bodies"]));
|
||||
let want = 10.0 * 125f64.sqrt();
|
||||
assert!((face - want).abs() < 1e-6 * want, "{face} vs {want}");
|
||||
}
|
||||
|
||||
/// A quarter cylinder sheet (R = 10, axis along x, its straight top edge at y = 10, z = 0 open)
|
||||
/// and a line 3D-sketched above it; returns the sheet's name and the line's id.
|
||||
fn cylinder_and_line(s: &mut Session) -> (String, String) {
|
||||
run(s, "sketch.create", json!({"plane": "YZ", "name": "Arc"}));
|
||||
let h = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
|
||||
let arc = first(&run(s, "sketch.arc.three_point", json!({"start": [0, -10], "end": [10, 0], "through": [h, -h]}))["created"]["curves"]);
|
||||
run(s, "sketch.finish", json!({}));
|
||||
let sheet = first(&run(s, "surface.extrude", json!({"sketch": "Arc", "curves": [arc], "distance": 10}))["created_bodies"]);
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Top"}));
|
||||
run(s, "sketch.options", json!({"sketch_3d": true}));
|
||||
let top = line3d(s, [0.0, 3.0, 12.0], [10.0, 3.0, 12.0]);
|
||||
run(s, "sketch.finish", json!({}));
|
||||
(sheet, top)
|
||||
}
|
||||
|
||||
/// The edge a surface built from the cylinder's top edge shares with it, measured with
|
||||
/// `inspect.continuity`: (normal angle in degrees, curvature jump per mm).
|
||||
fn seam(s: &mut Session, body: &str) -> (f64, f64) {
|
||||
let v = run(s, "inspect.continuity", json!({"body": body, "edges": [[5.0, 10.0, 0.0]]}));
|
||||
let e = v["bodies"][0]["edges"].as_array().and_then(|a| a.first()).cloned().unwrap_or_default();
|
||||
(e["normal_angle_deg"]["value"].as_f64().unwrap_or(f64::NAN), e["curvature_jump_per_mm"]["value"].as_f64().unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
/// G1 and G2 against a body: a network from the cylinder's open top edge to a line above, joined
|
||||
/// into the cylinder. Across the shared edge the normals agree within 0.1° for G1 and G2; the
|
||||
/// curvature jumps by much of the cylinder's 1/R with G1 and by under 1% of it with G2.
|
||||
#[test]
|
||||
fn g1_and_g2_networks_meet_a_body_across_its_edge() {
|
||||
let mut jumps = Vec::new();
|
||||
for g in ["g1", "g2"] {
|
||||
let mut s = Session::default();
|
||||
let (sheet, top) = cylinder_and_line(&mut s);
|
||||
let r = run(
|
||||
&mut s,
|
||||
"surface.boundary",
|
||||
json!({"u": [{"body": sheet, "edges": [[5.0, 10.0, 0.0]], "continuity": g}, {"sketch": "Top", "curves": [top]}], "operation": "join"}),
|
||||
);
|
||||
assert!(r["created_bodies"].as_array().is_none_or(Vec::is_empty), "a join makes no new body: {r}");
|
||||
let f = run(&mut s, "model.faces", json!({"body": sheet}));
|
||||
assert_eq!(f["faces"].as_array().map(Vec::len), Some(2), "the surface is sewn into the sheet: {f}");
|
||||
let (angle, jump) = seam(&mut s, &sheet);
|
||||
assert!(angle < 0.1, "{g}: normals {angle}° apart across the edge");
|
||||
jumps.push(jump);
|
||||
}
|
||||
let r = 10.0;
|
||||
assert!(jumps[0] > 0.2 / r, "G1 leaves the curvature behind: jump {}", jumps[0]);
|
||||
assert!(jumps[1] < 0.01 / r, "G2 carries the cylinder's curvature across: jump {}", jumps[1]);
|
||||
}
|
||||
|
||||
/// Networks that cannot be built are refused with a reason — by the command's own checks or by the
|
||||
/// kernel — and leave nothing behind; none of them panics.
|
||||
#[test]
|
||||
fn hostile_networks_are_refused() {
|
||||
let mut s = Session::default();
|
||||
let (sheet, top) = cylinder_and_line(&mut s);
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 40, 0]}));
|
||||
let boxed = s.world_state().bodies.last().map(|b| b.name.clone()).unwrap_or_default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Flat"}));
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
let far = line(&mut s, [0.0, 20.0], [10.0, 20.0]);
|
||||
let ring = first(&run(&mut s, "sketch.circle.center", json!({"center": [50, 50], "radius": 3}))["created"]["curves"]);
|
||||
let bodies = s.world_state().bodies.len();
|
||||
let edge = |g: &str| json!({"body": sheet, "edges": [[5.0, 10.0, 0.0]], "continuity": g});
|
||||
let many: Vec<Value> = (0..33).map(|_| json!(a)).collect();
|
||||
let cases: Vec<(Value, &str)> = vec![
|
||||
(json!({"u": "l1"}), "list"),
|
||||
(json!({"u": [42, a]}), "curve id"),
|
||||
(json!({"u": [{}, a]}), "one of"),
|
||||
(json!({"u": [{"curves": [], "sketch": "Flat"}, a]}), "sketch curves"),
|
||||
(json!({"u": [{"curves": ["nope"]}, a]}), "no sketch curve"),
|
||||
(json!({"u": [{"curves": [a], "sketch": "Nope"}, b]}), "no sketch"),
|
||||
(json!({"u": [{"body": "Nope", "edges": [[0, 0, 0]]}, a]}), "no body"),
|
||||
(json!({"u": [{"body": sheet, "edges": []}, a]}), "edges"),
|
||||
(json!({"u": [{"body": sheet, "edges": [["x", 0, 0]]}, a]}), "[x, y, z]"),
|
||||
(json!({"u": [{"body": sheet, "edges": [[1e300, 0, 0]]}, a]}), "[x, y, z]"),
|
||||
(json!({"u": [{"point": [0, 0]}, a]}), "point"),
|
||||
(json!({"u": [{"curves": [a], "body": sheet, "edges": [[5, 10, 0]]}, b]}), "one of"),
|
||||
(json!({"u": [{"curves": [a], "continuity": "g1"}, b]}), "body's edges"),
|
||||
(json!({"u": [edge("g3"), {"sketch": "Top", "curves": [top]}]}), "not supported yet"),
|
||||
(json!({"u": [edge("g9"), {"sketch": "Top", "curves": [top]}]}), "continuity"),
|
||||
(json!({"u": many}), "at most"),
|
||||
(json!({"u": [a, b], "curves": [a, b]}), "loop"),
|
||||
(json!({"u": [a, b], "crown": 1}), "loop"),
|
||||
(json!({"u": [a, b], "operation": "cut"}), "operation"),
|
||||
(json!({"u": [a, b], "operation": "join"}), "join"),
|
||||
(json!({"u": [a]}), "two curves"),
|
||||
(json!({"u": [], "v": [a, b]}), "`u`"),
|
||||
// The kernel's refusals: curves that miss, a closed curve, G1 on an edge between two faces.
|
||||
(json!({"u": [a, b], "v": [{"curves": [far]}, {"curves": [a]}]}), "don't meet"),
|
||||
(json!({"u": [{"curves": [ring]}, b]}), "closed"),
|
||||
(json!({"u": [{"body": boxed, "edges": [[45, 40, 0]], "continuity": "g1"}, b]}), "open edge"),
|
||||
(json!({"u": [a, {"point": [5, 2, 0]}, b]}), "point"),
|
||||
];
|
||||
for (params, want) in cases {
|
||||
let e = match s.execute("surface.boundary", ¶ms) {
|
||||
Ok(v) => panic!("{params} was built: {v}"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(!matches!(e, crate::EngineError::Internal(..)), "{params}: {e}");
|
||||
assert!(e.to_string().contains(want), "{params}: {e}");
|
||||
}
|
||||
assert_eq!(s.world_state().bodies.len(), bodies, "a refused network left a body behind");
|
||||
}
|
||||
|
||||
/// An edit (`timeline.redefine`) may turn a loop of sketch curves into the network of the same
|
||||
/// curves and back: the feature keeps its place and its name, and the surface its area.
|
||||
#[test]
|
||||
fn an_edit_turns_a_loop_into_a_network_and_back() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Square"}));
|
||||
let sides: Vec<String> = [[[0.0, 0.0], [10.0, 0.0]], [[10.0, 0.0], [10.0, 10.0]], [[10.0, 10.0], [0.0, 10.0]], [[0.0, 10.0], [0.0, 0.0]]]
|
||||
.iter()
|
||||
.map(|[a, b]| line(&mut s, *a, *b))
|
||||
.collect();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "surface.boundary", json!({"sketch": "Square", "curves": sides, "name": "Patch"}));
|
||||
let network = json!({"sketch": "Square", "u": [sides[0], sides[2]], "v": [sides[1], sides[3]]});
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Patch", "command": "surface.boundary", "params": network}));
|
||||
let kind = |s: &Session| s.doc.find_feature("Patch").map(|f| f.kind.clone());
|
||||
assert!(matches!(kind(&s), Some(solidcraft_doc::FeatureKind::SurfaceNetwork { .. })), "{:?}", kind(&s));
|
||||
let body = s.world_state().bodies.first().map(|b| b.name.clone()).unwrap_or_default();
|
||||
assert!((areas(&mut s, &body).0 - 100.0).abs() < 1e-6);
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Patch", "command": "surface.boundary", "params": {"sketch": "Square", "curves": sides}}));
|
||||
assert!(matches!(kind(&s), Some(solidcraft_doc::FeatureKind::SurfaceBoundary { .. })), "{:?}", kind(&s));
|
||||
assert!((areas(&mut s, &body).0 - 100.0).abs() < 1e-6);
|
||||
}
|
||||
@@ -46,7 +46,9 @@ use K::*;
|
||||
/// The parameters each command takes in world coordinates.
|
||||
fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
match id {
|
||||
"solid.extrude" => &[("face", Pick), ("face_body", Body), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)],
|
||||
"solid.extrude" => {
|
||||
&[("face", Pick), ("faces", Picks), ("face_body", Body), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)]
|
||||
}
|
||||
"solid.revolve" => &[("face", Pick), ("axis", Axis), ("to", Make), ("targets", Body), ("participants", Body)],
|
||||
"solid.box" | "solid.sphere" => &[("corner", Make), ("center", Make)],
|
||||
"solid.cylinder" | "solid.coil" | "solid.torus" => &[("base", Make), ("center", Make), ("axis", Dir)],
|
||||
|
||||
@@ -807,6 +807,8 @@ fn hostile_params_never_panic() {
|
||||
"continuity",
|
||||
"crown",
|
||||
"faces",
|
||||
"u",
|
||||
"v",
|
||||
];
|
||||
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
|
||||
let ids: Vec<&str> = command_specs()
|
||||
|
||||
@@ -54,6 +54,24 @@ fn differs(want: &Value, got: &Value) -> Option<String> {
|
||||
(!out.is_empty()).then(|| out.join("; "))
|
||||
}
|
||||
|
||||
/// Platform records only override the seeds explicitly verified on that platform.
|
||||
fn expected<'a>(file: &'a Value, seed: u64, platform: &str) -> &'a Value {
|
||||
let key = seed.to_string();
|
||||
file.get("platforms")
|
||||
.and_then(|platforms| platforms.get(platform))
|
||||
.and_then(|record| record.get("seeds"))
|
||||
.and_then(|seeds| seeds.get(&key))
|
||||
.unwrap_or(&file["seeds"][key])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_records_keep_shared_references_for_every_other_seed_and_platform() {
|
||||
let file = json!({"seeds":{"1":{"volume":10},"2":{"volume":20}},"platforms":{"windows":{"seeds":{"1":{"volume":11}}}}});
|
||||
assert_eq!(expected(&file, 1, "windows")["volume"], 11);
|
||||
assert_eq!(expected(&file, 1, "linux")["volume"], 10);
|
||||
assert_eq!(expected(&file, 2, "windows")["volume"], 20);
|
||||
}
|
||||
|
||||
fn check(seed: u64) {
|
||||
let got: BTreeMap<u64, Value> = BTreeMap::from([(seed, fuzz::golden(seed, MAX_STEPS))]);
|
||||
let _lock = FILE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -61,7 +79,7 @@ fn check(seed: u64) {
|
||||
let changed: Vec<(u64, String)> = got
|
||||
.iter()
|
||||
.filter_map(|(seed, g)| {
|
||||
let w = &file["seeds"][seed.to_string()];
|
||||
let w = expected(&file, *seed, std::env::consts::OS);
|
||||
if w.is_null() { Some((*seed, "new".to_string())) } else { differs(w, g).map(|d| (*seed, d)) }
|
||||
})
|
||||
.collect();
|
||||
@@ -75,20 +93,34 @@ fn check(seed: u64) {
|
||||
"What each robustness seed builds (crates/engine/src/tests/robustness_goldens.rs, docs/robustness.md). Record changes with ROBUSTNESS_GOLDENS_BLESS=\"why\"."
|
||||
);
|
||||
}
|
||||
let platform = std::env::consts::OS;
|
||||
let platform_record = file["platforms"][platform]["seeds"].is_object();
|
||||
for (seed, g) in &got {
|
||||
file["seeds"][seed.to_string()] = g.clone();
|
||||
if platform_record {
|
||||
if changed.iter().any(|(changed, _)| changed == seed) {
|
||||
file["platforms"][platform]["seeds"][seed.to_string()] = g.clone();
|
||||
}
|
||||
} else {
|
||||
file["seeds"][seed.to_string()] = g.clone();
|
||||
}
|
||||
}
|
||||
let mut notes = file["notes"].as_array().cloned().unwrap_or_default();
|
||||
let seeds: Vec<u64> = changed.iter().map(|(s, _)| *s).collect();
|
||||
// The parts of one record (the tests run side by side) share one note.
|
||||
match notes.last_mut() {
|
||||
Some(last) if last["reason"] == json!(reason) => {
|
||||
Some(last) if last["reason"] == json!(reason) && (!platform_record || last["platform"] == json!(platform)) => {
|
||||
let mut all: Vec<u64> = last["seeds"].as_array().into_iter().flatten().filter_map(Value::as_u64).chain(seeds).collect();
|
||||
all.sort_unstable();
|
||||
all.dedup();
|
||||
last["seeds"] = json!(all);
|
||||
}
|
||||
_ => notes.push(json!({"reason": reason, "seeds": seeds})),
|
||||
_ => {
|
||||
let mut note = json!({"reason": reason, "seeds": seeds});
|
||||
if platform_record {
|
||||
note["platform"] = json!(platform);
|
||||
}
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
file["notes"] = Value::Array(notes);
|
||||
// Seeds in order, one per line, so a change is a small diff.
|
||||
@@ -100,7 +132,11 @@ fn check(seed: u64) {
|
||||
s += &format!(" \"{k}\": {}{}\n", v, if i + 1 < n { "," } else { "" });
|
||||
}
|
||||
let notes: Vec<String> = file["notes"].as_array().into_iter().flatten().map(|n| format!(" {n}")).collect();
|
||||
s += &format!(" }},\n \"notes\": [\n{}\n ]\n}}\n", notes.join(",\n"));
|
||||
s += " },\n";
|
||||
if let Some(platforms) = file.get("platforms") {
|
||||
s += &format!(" \"platforms\": {platforms},\n");
|
||||
}
|
||||
s += &format!(" \"notes\": [\n{}\n ]\n}}\n", notes.join(",\n"));
|
||||
std::fs::write(path(), s).unwrap_or_else(|e| panic!("{}: {e}", path().display()));
|
||||
return;
|
||||
}
|
||||
|
||||
+53
-13
@@ -131,22 +131,62 @@ pub fn visible_bounds(s: &Session, sketches: bool) -> Aabb3 {
|
||||
b
|
||||
}
|
||||
|
||||
/// The two axes (in the camera's Z-up frame) of the plane the grid lies in, for a view whose
|
||||
/// direction toward the eye is `back` (in that frame). The ground (frame XY) unless it is seen
|
||||
/// nearly edge-on, as in the Front or Right view; then the upright origin plane that faces the
|
||||
/// view more, so the grid stays visible (#1470).
|
||||
pub fn grid_plane(back: Vec3) -> (Vec3, Vec3) {
|
||||
// About 11.5° above or below the ground.
|
||||
const EDGE_ON: f64 = 0.2;
|
||||
if back.z.abs() >= EDGE_ON || !back.z.is_finite() {
|
||||
(Vec3::X, Vec3::Y)
|
||||
} else if back.y.abs() >= back.x.abs() {
|
||||
(Vec3::X, Vec3::Z)
|
||||
} else {
|
||||
(Vec3::Y, Vec3::Z)
|
||||
/// The origin plane a grid lies on.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum GridPlane {
|
||||
/// The ground: the camera frame's XY.
|
||||
#[default]
|
||||
Ground,
|
||||
/// Upright, facing a view along ±Y (the Front or Back view): the frame's XZ.
|
||||
Xz,
|
||||
/// Upright, facing a view along ±X (the Left or Right view): the frame's YZ.
|
||||
Yz,
|
||||
}
|
||||
|
||||
impl GridPlane {
|
||||
/// The plane's two axes, in the camera's Z-up frame.
|
||||
pub fn axes(self) -> (Vec3, Vec3) {
|
||||
match self {
|
||||
GridPlane::Ground => (Vec3::X, Vec3::Y),
|
||||
GridPlane::Xz => (Vec3::X, Vec3::Z),
|
||||
GridPlane::Yz => (Vec3::Y, Vec3::Z),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The plane to draw the grid on for a view whose direction toward the eye is `back` (in the
|
||||
/// camera's Z-up frame), given the plane the last frame drew (`last`).
|
||||
///
|
||||
/// The ground (frame XY) unless it is seen nearly edge-on, as in the Front or Right view; then
|
||||
/// the upright origin plane facing the view, so the grid stays visible (#1470). Both thresholds
|
||||
/// are further for leaving than for entering, so a view hovering at one does not flicker the
|
||||
/// grid, and the upright plane changes to the other only close to a side view, never at the 45°
|
||||
/// crossing (#1993).
|
||||
pub fn grid_plane(back: Vec3, last: GridPlane) -> GridPlane {
|
||||
// About 11.5° above or below the ground to leave it, and 15° to return.
|
||||
const EDGE_ON: f64 = 0.2;
|
||||
const EDGE_LEAVE: f64 = 0.26;
|
||||
// About 15° from the Front/Back or Left/Right view: only that close does the wall change.
|
||||
const SIDE_VIEW: f64 = 0.966;
|
||||
let horizontal = (back.x * back.x + back.y * back.y).sqrt();
|
||||
if !back.x.is_finite() || !back.y.is_finite() || !back.z.is_finite() || !horizontal.is_finite() {
|
||||
return GridPlane::Ground;
|
||||
}
|
||||
if last == GridPlane::Ground && back.z.abs() <= EDGE_ON && horizontal > 0.0 {
|
||||
return if back.y.abs() >= back.x.abs() { GridPlane::Xz } else { GridPlane::Yz };
|
||||
}
|
||||
if last != GridPlane::Ground && back.z.abs() <= EDGE_LEAVE && horizontal > 0.0 {
|
||||
if back.x.abs() / horizontal >= SIDE_VIEW {
|
||||
return GridPlane::Yz;
|
||||
}
|
||||
if back.y.abs() / horizontal >= SIDE_VIEW {
|
||||
return GridPlane::Xz;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
GridPlane::Ground
|
||||
}
|
||||
|
||||
/// Grid spacing (minor, major) for a view of the given half height.
|
||||
pub fn grid_step(half_height: f64) -> (f64, f64) {
|
||||
let raw = (half_height / 10.0).max(1e-3);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Geometry foundation for SolidCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
|
||||
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
|
||||
//! (B-spline basis and NURBS surfaces in [`bspline`], one-surface fills of closed loops in
|
||||
//! [`fill`], T-splines in [`tspline`]). Units are millimetres.
|
||||
//! (B-spline basis and NURBS surfaces in [`bspline`], spline spaces and the operators that make
|
||||
//! splines compatible in [`spline_space`], one-surface fills of closed loops in [`fill`],
|
||||
//! surfaces through curve networks in [`network`], T-splines in [`tspline`]). Units are millimetres.
|
||||
//!
|
||||
//! This crate has no knowledge of sketches, documents or a modelling backend; every other crate
|
||||
//! builds on it.
|
||||
@@ -19,8 +20,11 @@ pub mod fill;
|
||||
pub mod fill_general;
|
||||
mod mesh;
|
||||
pub mod mesh_distance;
|
||||
pub mod network;
|
||||
mod plane;
|
||||
mod profile;
|
||||
pub mod section;
|
||||
pub mod spline_space;
|
||||
pub mod tess;
|
||||
pub mod tspline;
|
||||
mod vec;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,291 @@
|
||||
//! Backend-neutral section geometry in a model-space plane, in millimetres and radians.
|
||||
//!
|
||||
//! Curve and wire indices identify items in this result only, not persistent model geometry.
|
||||
//! Producers own source identity, topology and accuracy evidence. [`SectionGeometry::validate_structure`]
|
||||
//! checks bounded payloads, references and connected wires; it does not certify simple loops,
|
||||
//! winding, hole nesting, region separation, source coincidence or an approximation bound.
|
||||
//! Material producers must supply CCW outer loops and CW holes in the plane's x/y basis,
|
||||
//! whose normal is x cross y. Conversion preserves those supplied orientations.
|
||||
//!
|
||||
//! Stored coordinates are finite and at most 1e12 mm in absolute value. Radii and conic
|
||||
//! weights lie in 1e-12..=1e12; arc starts in -1e6..=1e6 radians and absolute sweeps in
|
||||
//! 1e-12..=one turn (with 1e-12 radians of allowance). Positive conic weights are the supported
|
||||
//! subset. These arithmetic limits do not promise a small absolute error at extreme magnitudes.
|
||||
//! Closure tolerance is 1e-9..=1 mm, independent of approximation accuracy.
|
||||
//!
|
||||
//! Validate after deserialization and before use or serialization. The DTOs deliberately retain
|
||||
//! [`Plane`]'s existing serde representation and construction behavior. Validation after parsing
|
||||
//! cannot bound parsing allocations: an encoded-input boundary must also limit bytes and nesting.
|
||||
//! The per-wire/per-region conversions revalidate the whole result on each call; they are not an
|
||||
//! optimized batch-conversion API. Existing profile area/containment methods are not general
|
||||
//! exact or certified measures for conics and freeform curves. Profile values contain geometry
|
||||
//! only: callers retain the original result's accuracy declarations and local identities.
|
||||
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Loop2, MAX_BEZIER_DEGREE, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
|
||||
/// Aggregate input records/references, and the selected conversion's output items, at most.
|
||||
/// Input counts curves, wires, regions, contacts, curve uses and hole references together.
|
||||
pub const MAX_ITEMS: usize = 100_000;
|
||||
const MAX_NUMBER: f64 = 1e12;
|
||||
const MIN_POSITIVE: f64 = 1e-12;
|
||||
const MAX_START_ANGLE: f64 = 1e6;
|
||||
const FRAME_TOLERANCE: f64 = 1e-8;
|
||||
|
||||
/// A section's geometric result, without source identity, freshness or persistence semantics.
|
||||
/// Empty, open-wire and contact-only results remain distinct; no material is inferred from them.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionGeometry {
|
||||
pub plane: Plane,
|
||||
pub curves: Vec<SectionCurve>,
|
||||
pub wires: Vec<SectionWire>,
|
||||
pub regions: Vec<SectionRegion>,
|
||||
/// Isolated contacts in plane coordinates, not invented closed regions.
|
||||
pub contacts: Vec<Vec2>,
|
||||
}
|
||||
|
||||
/// One curve and the producer's declared accuracy relative to its source.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionCurve {
|
||||
pub geometry: Seg2,
|
||||
pub accuracy: CurveAccuracy,
|
||||
}
|
||||
|
||||
/// How the producer represents its source; structure validation cannot certify this declaration.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum CurveAccuracy {
|
||||
/// An analytic representation without fitting; not a certificate about the source or topology.
|
||||
Analytic,
|
||||
/// A producer-established bound, not merely a requested fitting tolerance.
|
||||
Bounded { max_deviation_mm: f64 },
|
||||
/// A deviation measured at samples only, with no bound between them. Zero remains sampled.
|
||||
Sampled { max_sample_deviation_mm: f64 },
|
||||
}
|
||||
|
||||
/// An ordered connected chain. `closed` is explicit, never inferred or repaired.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionWire {
|
||||
pub curves: Vec<CurveUse>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
/// An oriented use of a curve by its result-local index.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CurveUse {
|
||||
pub curve: u32,
|
||||
pub forward: bool,
|
||||
}
|
||||
|
||||
/// A producer-declared material region referring to closed wires. Shared wires are permitted.
|
||||
/// The outer wire cannot also be a hole, and a hole cannot occur twice in this one region.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionRegion {
|
||||
pub outer: u32,
|
||||
pub holes: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("section geometry: {0}")]
|
||||
pub struct Invalid(pub &'static str);
|
||||
|
||||
type Result<T> = std::result::Result<T, Invalid>;
|
||||
|
||||
fn require(condition: bool, message: &'static str) -> Result<()> {
|
||||
if condition { Ok(()) } else { Err(Invalid(message)) }
|
||||
}
|
||||
|
||||
struct Budget(usize);
|
||||
impl Budget {
|
||||
fn add(&mut self, n: usize) -> Result<()> {
|
||||
self.0 = self.0.checked_add(n).ok_or(Invalid("item budget"))?;
|
||||
require(self.0 <= MAX_ITEMS, "item budget")
|
||||
}
|
||||
}
|
||||
|
||||
fn numbers(values: impl IntoIterator<Item = f64>) -> Result<()> {
|
||||
require(values.into_iter().all(|v| v.is_finite() && v.abs() <= MAX_NUMBER), "non-finite or excessive coordinate")
|
||||
}
|
||||
|
||||
fn point(p: Vec2) -> Result<()> {
|
||||
numbers([p.x, p.y])
|
||||
}
|
||||
|
||||
fn vector(p: Vec3) -> Result<()> {
|
||||
numbers([p.x, p.y, p.z])
|
||||
}
|
||||
|
||||
/// Strictly check an existing plane without re-orthonormalizing it or changing its serde format.
|
||||
/// Unit-length and orthogonality checks use 1e-8; the normal is derived as x cross y.
|
||||
pub fn validate_plane(plane: &Plane) -> Result<()> {
|
||||
vector(plane.origin)?;
|
||||
vector(plane.x)?;
|
||||
vector(plane.y)?;
|
||||
require(
|
||||
(plane.x.len() - 1.0).abs() <= FRAME_TOLERANCE
|
||||
&& (plane.y.len() - 1.0).abs() <= FRAME_TOLERANCE
|
||||
&& plane.x.dot(plane.y).abs() <= FRAME_TOLERANCE,
|
||||
"nonorthonormal plane",
|
||||
)
|
||||
}
|
||||
|
||||
fn controls(points: &[Vec2]) -> Result<()> {
|
||||
for p in points {
|
||||
point(*p)?;
|
||||
}
|
||||
let Some((first, rest)) = points.split_first() else { return Err(Invalid("empty controls")) };
|
||||
require(rest.iter().any(|p| p != first), "collapsed curve")
|
||||
}
|
||||
|
||||
fn curve(curve: &SectionCurve) -> Result<()> {
|
||||
match curve.geometry {
|
||||
Seg2::Line { a, b } => controls(&[a, b])?,
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
point(center)?;
|
||||
require(radius.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&radius), "arc radius")?;
|
||||
require(start.is_finite() && start.abs() <= MAX_START_ANGLE, "arc start angle")?;
|
||||
require(sweep.is_finite() && (MIN_POSITIVE..=TAU + 1e-12).contains(&sweep.abs()), "arc sweep")?;
|
||||
}
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => controls(&[p0, p1, p2, p3])?,
|
||||
Seg2::Conic { a, apex, b, w } => {
|
||||
controls(&[a, apex, b])?;
|
||||
require(w.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&w), "conic weight")?;
|
||||
}
|
||||
Seg2::Bezier { n, ref p } => {
|
||||
require((4..=MAX_BEZIER_DEGREE).contains(&usize::from(n)), "Bezier degree")?;
|
||||
// The unused slots are serialized too: reject hidden non-finite/excessive values.
|
||||
for q in p {
|
||||
point(*q)?;
|
||||
}
|
||||
controls(p.get(..=usize::from(n)).ok_or(Invalid("Bezier degree"))?)?;
|
||||
}
|
||||
}
|
||||
require(curve.geometry.start().is_finite() && curve.geometry.end().is_finite(), "non-finite curve endpoint")?;
|
||||
match curve.accuracy {
|
||||
CurveAccuracy::Analytic => Ok(()),
|
||||
CurveAccuracy::Bounded { max_deviation_mm } => deviation(max_deviation_mm),
|
||||
CurveAccuracy::Sampled { max_sample_deviation_mm } => deviation(max_sample_deviation_mm),
|
||||
}
|
||||
}
|
||||
|
||||
fn deviation(value: f64) -> Result<()> {
|
||||
require(value.is_finite() && (0.0..=MAX_NUMBER).contains(&value), "accuracy deviation")
|
||||
}
|
||||
|
||||
impl SectionGeometry {
|
||||
/// Check finite supported payloads, aggregate counts, references and wire connectivity.
|
||||
/// This does not certify material validity, winding, nesting, source identity or accuracy.
|
||||
/// Count preflight precedes geometry evaluation; encoded input needs separate parsing limits.
|
||||
pub fn validate_structure(&self, closure_tolerance_mm: f64) -> Result<()> {
|
||||
let mut budget = Budget(0);
|
||||
budget.add(self.curves.len())?;
|
||||
budget.add(self.wires.len())?;
|
||||
budget.add(self.regions.len())?;
|
||||
budget.add(self.contacts.len())?;
|
||||
for wire in &self.wires {
|
||||
budget.add(wire.curves.len())?;
|
||||
}
|
||||
for region in &self.regions {
|
||||
budget.add(region.holes.len())?;
|
||||
}
|
||||
require(closure_tolerance_mm.is_finite() && (1e-9..=1.0).contains(&closure_tolerance_mm), "closure tolerance must be between 1e-9 and 1 mm")?;
|
||||
validate_plane(&self.plane)?;
|
||||
for c in &self.curves {
|
||||
curve(c)?;
|
||||
}
|
||||
for p in &self.contacts {
|
||||
point(*p)?;
|
||||
}
|
||||
for wire in &self.wires {
|
||||
require(!wire.curves.is_empty(), "empty wire")?;
|
||||
let mut first = None;
|
||||
let mut last: Option<Vec2> = None;
|
||||
for use_ in &wire.curves {
|
||||
let c = &self.curves.get(use_.curve as usize).ok_or(Invalid("curve reference"))?.geometry;
|
||||
let (a, b) = (c.start(), c.end());
|
||||
let (start, end) = if use_.forward { (a, b) } else { (b, a) };
|
||||
require(last.is_none_or(|p| p.dist(start) <= closure_tolerance_mm), "disconnected wire")?;
|
||||
first.get_or_insert(start);
|
||||
last = Some(end);
|
||||
}
|
||||
if wire.closed {
|
||||
require(first.zip(last).is_some_and(|(a, b)| a.dist(b) <= closure_tolerance_mm), "open closed wire")?;
|
||||
}
|
||||
}
|
||||
// One stamp array for all regions: neither pairwise duplicate checks nor clearing the
|
||||
// entire array per region. Sharing the same wire between different regions is allowed.
|
||||
let mut seen = vec![usize::MAX; self.wires.len()];
|
||||
for (stamp, region) in self.regions.iter().enumerate() {
|
||||
for id in std::iter::once(®ion.outer).chain(®ion.holes) {
|
||||
let wire = self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?;
|
||||
require(wire.closed, "region boundary is not closed")?;
|
||||
let mark = seen.get_mut(*id as usize).ok_or(Invalid("wire reference"))?;
|
||||
require(*mark != stamp, "duplicate region boundary")?;
|
||||
*mark = stamp;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy one closed wire to a profile loop, preserving its oriented curve uses.
|
||||
/// Revalidates the whole result; an open wire is never silently closed. Floating-point
|
||||
/// reversal must also keep the converted loop connected within the same tolerance.
|
||||
pub fn to_loop(&self, wire: u32, closure_tolerance_mm: f64) -> Result<Loop2> {
|
||||
self.validate_structure(closure_tolerance_mm)?;
|
||||
let wire = self.wires.get(wire as usize).ok_or(Invalid("wire reference"))?;
|
||||
require(wire.closed, "wire is not closed")?;
|
||||
let mut budget = Budget(0);
|
||||
budget.add(1)?;
|
||||
budget.add(wire.curves.len())?;
|
||||
self.copy_loop(wire, closure_tolerance_mm)
|
||||
}
|
||||
|
||||
/// Copy one declared region to a profile, preserving winding and hole order.
|
||||
/// Revalidates the whole result and preflights expanded output before allocating it.
|
||||
/// This does not certify the producer's region. The original result is unchanged; the
|
||||
/// returned profile contains no accuracy declarations or result-local identities.
|
||||
pub fn to_region(&self, region: u32, closure_tolerance_mm: f64) -> Result<Region2> {
|
||||
self.validate_structure(closure_tolerance_mm)?;
|
||||
let region = self.regions.get(region as usize).ok_or(Invalid("region reference"))?;
|
||||
let mut budget = Budget(0);
|
||||
budget.add(1)?;
|
||||
budget.add(region.holes.len())?;
|
||||
for id in std::iter::once(®ion.outer).chain(®ion.holes) {
|
||||
let wire = self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?;
|
||||
budget.add(wire.curves.len())?;
|
||||
}
|
||||
let outer = self.copy_loop(self.wires.get(region.outer as usize).ok_or(Invalid("wire reference"))?, closure_tolerance_mm)?;
|
||||
let mut holes = Vec::with_capacity(region.holes.len());
|
||||
for id in ®ion.holes {
|
||||
holes.push(self.copy_loop(self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?, closure_tolerance_mm)?);
|
||||
}
|
||||
Ok(Region2 { outer, holes })
|
||||
}
|
||||
|
||||
// Called only while the publicly validated result remains immutably borrowed and the
|
||||
// selected output's expansion has been counted. Index access stays fallible nonetheless.
|
||||
fn copy_loop(&self, wire: &SectionWire, closure_tolerance_mm: f64) -> Result<Loop2> {
|
||||
let mut segs = Vec::with_capacity(wire.curves.len());
|
||||
for use_ in &wire.curves {
|
||||
let c = self.curves.get(use_.curve as usize).ok_or(Invalid("curve reference"))?.geometry;
|
||||
segs.push(if use_.forward { c } else { c.reversed() });
|
||||
}
|
||||
let loop_ = Loop2 { segs };
|
||||
// Reversing an arc changes its stored start to start + sweep. Subtracting the sweep
|
||||
// at its new end need not recover the original angle exactly at large radii.
|
||||
require(loop_.is_closed(closure_tolerance_mm), "converted loop is not closed within tolerance")?;
|
||||
Ok(loop_)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "section_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,409 @@
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use super::*;
|
||||
use crate::{MAX_BEZIER_CTRL, bezier};
|
||||
|
||||
const TOL: f64 = 1e-6;
|
||||
|
||||
fn empty(plane: Plane) -> SectionGeometry {
|
||||
SectionGeometry { plane, curves: vec![], wires: vec![], regions: vec![], contacts: vec![] }
|
||||
}
|
||||
|
||||
fn analytic(geometry: Seg2) -> SectionCurve {
|
||||
SectionCurve { geometry, accuracy: CurveAccuracy::Analytic }
|
||||
}
|
||||
|
||||
fn used(curve: u32, forward: bool) -> CurveUse {
|
||||
CurveUse { curve, forward }
|
||||
}
|
||||
|
||||
fn plate() -> SectionGeometry {
|
||||
let p = Vec2::new;
|
||||
SectionGeometry {
|
||||
plane: Plane::XY.offset(10.0),
|
||||
curves: vec![
|
||||
// Deliberately stored backwards: the wire's use restores the CCW rectangle.
|
||||
analytic(Seg2::Line { a: p(100.0, 0.0), b: p(0.0, 0.0) }),
|
||||
analytic(Seg2::Line { a: p(100.0, 0.0), b: p(100.0, 60.0) }),
|
||||
analytic(Seg2::Line { a: p(100.0, 60.0), b: p(0.0, 60.0) }),
|
||||
analytic(Seg2::Line { a: p(0.0, 60.0), b: p(0.0, 0.0) }),
|
||||
analytic(Seg2::Arc { center: p(50.0, 30.0), radius: 5.0, start: 0.0, sweep: PI }),
|
||||
analytic(Seg2::Arc { center: p(50.0, 30.0), radius: 5.0, start: PI, sweep: PI }),
|
||||
],
|
||||
wires: vec![
|
||||
SectionWire { curves: vec![used(0, false), used(1, true), used(2, true), used(3, true)], closed: true },
|
||||
SectionWire { curves: vec![used(5, false), used(4, false)], closed: true },
|
||||
],
|
||||
regions: vec![SectionRegion { outer: 0, holes: vec![1] }],
|
||||
contacts: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_frame_preserves_named_translated_tilted_offset_and_flip_conventions() {
|
||||
for plane in [Plane::XY, Plane::XZ, Plane::YZ] {
|
||||
validate_plane(&plane).unwrap();
|
||||
}
|
||||
assert_eq!(Plane::XZ.normal(), Vec3::Y);
|
||||
assert_eq!(Plane::XZ.to_world(Vec2::new(3.0, 4.0)), Vec3::new(3.0, 0.0, -4.0));
|
||||
let plane = Plane::from_normal(Vec3::new(13.0, -7.0, 19.0), Vec3::new(2.0, -3.0, 5.0)).unwrap();
|
||||
validate_plane(&plane).unwrap();
|
||||
let local = Vec2::new(7.0, -11.0);
|
||||
let height = 4.5;
|
||||
let point = plane.to_world(local) + plane.normal() * height;
|
||||
assert!(plane.to_local(point).dist(local) < 1e-12);
|
||||
assert!((plane.height(point) - height).abs() < 1e-12);
|
||||
let moved = plane.offset(8.0);
|
||||
validate_plane(&moved).unwrap();
|
||||
assert!((plane.height(moved.origin) - 8.0).abs() < 1e-12);
|
||||
for about_y in [false, true] {
|
||||
let flipped = moved.flipped(about_y);
|
||||
validate_plane(&flipped).unwrap();
|
||||
assert_eq!(flipped.origin, moved.origin);
|
||||
assert_eq!(flipped.normal(), -moved.normal());
|
||||
assert_eq!(flipped.flipped(about_y), moved);
|
||||
let on_plane = moved.to_world(local);
|
||||
let mirrored = if about_y { Vec2::new(-local.x, local.y) } else { Vec2::new(local.x, -local.y) };
|
||||
assert!(flipped.to_local(on_plane).dist(mirrored) < 1e-12);
|
||||
assert!((flipped.height(point) + moved.height(point)).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_frame_validation_does_not_repair_raw_or_deserialized_axes() {
|
||||
let bad = Plane { x: Vec3::X * 2.0, ..Plane::XY };
|
||||
assert!(Plane::new(bad.origin, bad.x, bad.y).is_some());
|
||||
assert_eq!(validate_plane(&bad), Err(Invalid("nonorthonormal plane")));
|
||||
assert_eq!(bad.x, Vec3::X * 2.0);
|
||||
let restored: Plane = serde_json::from_value(serde_json::json!({
|
||||
"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [1, 1, 0]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(validate_plane(&restored), Err(Invalid("nonorthonormal plane")));
|
||||
for plane in [
|
||||
Plane { origin: Vec3::new(f64::NAN, 0.0, 0.0), ..Plane::XY },
|
||||
Plane { origin: Vec3::new(1e12 + 1.0, 0.0, 0.0), ..Plane::XY },
|
||||
Plane { x: Vec3::new(f64::MAX, 0.0, 0.0), ..Plane::XY },
|
||||
Plane { y: Vec3::new(0.0, f64::INFINITY, 0.0), ..Plane::XY },
|
||||
Plane { x: Vec3::ZERO, ..Plane::XY },
|
||||
Plane { y: Vec3::X, ..Plane::XY },
|
||||
] {
|
||||
assert!(validate_plane(&plane).is_err());
|
||||
}
|
||||
validate_plane(&Plane { origin: Vec3::new(1e12, -1e12, 1e12), ..Plane::XY }).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_arc_region_conversion_preserves_winding_and_analytic_area() {
|
||||
let section = plate();
|
||||
let before = section.clone();
|
||||
section.validate_structure(TOL).unwrap();
|
||||
let outer = section.to_loop(0, TOL).unwrap();
|
||||
let hole = section.to_loop(1, TOL).unwrap();
|
||||
assert!(outer.is_closed(TOL) && hole.is_closed(TOL));
|
||||
assert!((outer.signed_area() - 6000.0).abs() < 1e-10);
|
||||
assert!((hole.signed_area() + 25.0 * PI).abs() < 1e-10);
|
||||
let region = section.to_region(0, TOL).unwrap();
|
||||
assert_eq!(region.outer, outer);
|
||||
assert_eq!(region.holes, vec![hole]);
|
||||
assert!((region.area() - (6000.0 - 25.0 * PI)).abs() < 1e-10);
|
||||
assert_eq!(section, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_contacts_and_open_wires_remain_distinct_without_inferred_material() {
|
||||
let empty = empty(Plane::XY);
|
||||
empty.validate_structure(TOL).unwrap();
|
||||
assert_eq!(empty.to_region(0, TOL), Err(Invalid("region reference")));
|
||||
let mut contact = empty.clone();
|
||||
contact.contacts.push(Vec2::new(2.0, 3.0));
|
||||
contact.validate_structure(TOL).unwrap();
|
||||
assert_ne!(contact, empty);
|
||||
let mut open = empty.clone();
|
||||
open.curves = vec![analytic(Seg2::Line { a: Vec2::ZERO, b: Vec2::X }), analytic(Seg2::Line { a: Vec2::X, b: Vec2::new(1.0, 1.0) })];
|
||||
open.wires = vec![SectionWire { curves: vec![used(0, true), used(1, true)], closed: false }];
|
||||
open.validate_structure(TOL).unwrap();
|
||||
assert_eq!(open.to_loop(0, TOL), Err(Invalid("wire is not closed")));
|
||||
open.regions.push(SectionRegion { outer: 0, holes: vec![] });
|
||||
assert_eq!(open.validate_structure(TOL), Err(Invalid("region boundary is not closed")));
|
||||
// Even a geometrically closed chain remains explicitly open when the producer says so.
|
||||
let mut declared_open = plate();
|
||||
declared_open.regions.clear();
|
||||
declared_open.wires[0].closed = false;
|
||||
declared_open.validate_structure(TOL).unwrap();
|
||||
assert_eq!(declared_open.to_loop(0, TOL), Err(Invalid("wire is not closed")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_references_empty_disconnected_and_unclosed_wires_fail() {
|
||||
let mut section = plate();
|
||||
section.wires[0].curves[0].curve = u32::MAX;
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("curve reference")));
|
||||
let mut section = plate();
|
||||
section.wires[0].curves.clear();
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("empty wire")));
|
||||
let mut section = plate();
|
||||
section.wires[0].curves[1].forward = false;
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("disconnected wire")));
|
||||
let mut section = plate();
|
||||
section.wires[0].curves.pop();
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("open closed wire")));
|
||||
let mut section = plate();
|
||||
section.regions[0].outer = u32::MAX;
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("wire reference")));
|
||||
let mut section = plate();
|
||||
section.regions[0].holes[0] = u32::MAX;
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("wire reference")));
|
||||
assert_eq!(plate().to_loop(u32::MAX, TOL), Err(Invalid("wire reference")));
|
||||
assert_eq!(plate().to_region(u32::MAX, TOL), Err(Invalid("region reference")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_boundaries_are_local_to_a_region_and_large_reference_tables_are_bounded() {
|
||||
let mut section = plate();
|
||||
section.regions[0].holes.push(1);
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("duplicate region boundary")));
|
||||
let mut section = plate();
|
||||
section.regions[0].holes.push(0);
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("duplicate region boundary")));
|
||||
let mut section = plate();
|
||||
section.regions.push(section.regions[0].clone());
|
||||
section.validate_structure(TOL).unwrap();
|
||||
assert_eq!(section.to_region(0, TOL).unwrap(), section.to_region(1, TOL).unwrap());
|
||||
// Deliberately coincident boundaries: this tests a large legal reference graph, not material.
|
||||
let mut many = empty(Plane::XY);
|
||||
many.curves.push(analytic(Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep: TAU }));
|
||||
many.wires = vec![SectionWire { curves: vec![used(0, true)], closed: true }; 201];
|
||||
many.regions = vec![SectionRegion { outer: 0, holes: (1..=200).collect() }; 200];
|
||||
many.validate_structure(TOL).unwrap();
|
||||
let last = many.regions.last_mut().unwrap();
|
||||
*last.holes.last_mut().unwrap() = 1;
|
||||
assert_eq!(many.validate_structure(TOL), Err(Invalid("duplicate region boundary")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_counts_fail_before_geometry_or_reference_inspection() {
|
||||
let mut section = empty(Plane::XY);
|
||||
section.contacts = vec![Vec2::new(f64::NAN, 0.0); MAX_ITEMS + 1];
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
|
||||
let mut section = empty(Plane::XY);
|
||||
section.wires.push(SectionWire { curves: vec![used(u32::MAX, false); MAX_ITEMS], closed: false });
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
|
||||
let mut section = empty(Plane::XY);
|
||||
section.regions.push(SectionRegion { outer: u32::MAX, holes: vec![u32::MAX; MAX_ITEMS] });
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
|
||||
let mut section = empty(Plane::XY);
|
||||
section.contacts = vec![Vec2::ZERO; MAX_ITEMS / 2];
|
||||
section.wires.push(SectionWire { curves: vec![used(u32::MAX, false); MAX_ITEMS / 2], closed: false });
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
|
||||
// Exactly the item limit is accepted, independently of these points' material meaning.
|
||||
let mut boundary = empty(Plane::XY);
|
||||
boundary.contacts = vec![Vec2::ZERO; MAX_ITEMS];
|
||||
boundary.validate_structure(TOL).unwrap();
|
||||
let mut count = Budget(MAX_ITEMS);
|
||||
assert_eq!(count.add(usize::MAX), Err(Invalid("item budget")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_tolerance_is_bounded_and_not_used_to_collapse_short_curves() {
|
||||
for tolerance in [f64::NAN, f64::INFINITY, -1.0, 0.0, 1e-10, 1.01] {
|
||||
assert!(plate().validate_structure(tolerance).is_err());
|
||||
}
|
||||
for tolerance in [1e-9, 1.0] {
|
||||
plate().validate_structure(tolerance).unwrap();
|
||||
}
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves.push(analytic(Seg2::Line { a: Vec2::ZERO, b: Vec2::new(1e-10, 0.0) }));
|
||||
section.validate_structure(1.0).unwrap();
|
||||
let mut gap = plate();
|
||||
if let Seg2::Line { b, .. } = &mut gap.curves[3].geometry {
|
||||
b.x = 5e-7;
|
||||
}
|
||||
gap.validate_structure(1e-6).unwrap();
|
||||
assert_eq!(gap.validate_structure(1e-8), Err(Invalid("open closed wire")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_number_and_domain_errors_are_rejected_before_evaluation() {
|
||||
let p = Vec2::new;
|
||||
let arc = |radius, start, sweep| Seg2::Arc { center: Vec2::ZERO, radius, start, sweep };
|
||||
let conic = |w| Seg2::Conic { a: Vec2::X, apex: p(1.0, 1.0), b: Vec2::Y, w };
|
||||
let invalid = [
|
||||
Seg2::Line { a: Vec2::ZERO, b: Vec2::ZERO },
|
||||
Seg2::Line { a: Vec2::ZERO, b: p(f64::NAN, 0.0) },
|
||||
Seg2::Line { a: Vec2::ZERO, b: p(f64::MAX, 0.0) },
|
||||
arc(-1.0, 0.0, PI),
|
||||
arc(0.0, 0.0, PI),
|
||||
arc(1e-13, 0.0, PI),
|
||||
arc(1e12 + 1.0, 0.0, PI),
|
||||
arc(f64::INFINITY, 0.0, PI),
|
||||
arc(1.0, f64::NAN, PI),
|
||||
arc(1.0, 1e6 + 1.0, PI),
|
||||
arc(1.0, 0.0, 0.0),
|
||||
arc(1.0, 0.0, 1e-13),
|
||||
arc(1.0, 0.0, TAU + 1e-8),
|
||||
arc(1.0, 0.0, f64::INFINITY),
|
||||
conic(-0.5),
|
||||
conic(0.0),
|
||||
conic(1e-13),
|
||||
conic(1e12 + 1.0),
|
||||
conic(f64::NAN),
|
||||
Seg2::Cubic { p0: Vec2::X, p1: Vec2::X, p2: Vec2::X, p3: Vec2::X },
|
||||
Seg2::Conic { a: Vec2::X, apex: Vec2::X, b: Vec2::X, w: 1.0 },
|
||||
];
|
||||
for geometry in invalid {
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves.push(analytic(geometry));
|
||||
assert!(section.validate_structure(TOL).is_err(), "accepted {geometry:?}");
|
||||
}
|
||||
let mut section = empty(Plane::XY);
|
||||
section.contacts.push(p(f64::INFINITY, 0.0));
|
||||
assert!(section.validate_structure(TOL).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bezier_degree_and_every_serialized_control_slot_are_validated() {
|
||||
for degree in [4u8, 9] {
|
||||
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
|
||||
for (i, point) in p.iter_mut().enumerate().take(usize::from(degree) + 1) {
|
||||
*point = Vec2::new(i as f64, (i % 2) as f64);
|
||||
}
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves.push(analytic(Seg2::Bezier { n: degree, p }));
|
||||
section.validate_structure(TOL).unwrap();
|
||||
for wrong in [0, 3, 10, u8::MAX] {
|
||||
section.curves[0].geometry = Seg2::Bezier { n: wrong, p };
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("Bezier degree")));
|
||||
}
|
||||
}
|
||||
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
|
||||
p[4] = Vec2::X;
|
||||
for value in [f64::NAN, f64::INFINITY, 1e12 + 1.0] {
|
||||
p[MAX_BEZIER_CTRL - 1] = Vec2::new(value, 0.0);
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves.push(analytic(Seg2::Bezier { n: 4, p }));
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("non-finite or excessive coordinate")));
|
||||
}
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves.push(analytic(Seg2::Bezier { n: 4, p: [Vec2::ZERO; MAX_BEZIER_CTRL] }));
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("collapsed curve")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_curve_limits_keep_evaluation_finite_without_claiming_accuracy() {
|
||||
let p = Vec2::new;
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves = vec![
|
||||
analytic(Seg2::Line { a: p(-1e12, 1e12), b: p(1e12, -1e12) }),
|
||||
analytic(Seg2::Arc { center: p(1e12, 1e12), radius: 1e12, start: 1e6, sweep: -TAU }),
|
||||
analytic(Seg2::Arc { center: Vec2::ZERO, radius: 1e-12, start: 0.0, sweep: 1e-12 }),
|
||||
analytic(Seg2::Conic { a: p(-1e12, 0.0), apex: p(0.0, 1e12), b: p(1e12, 0.0), w: 1e12 }),
|
||||
analytic(Seg2::Conic { a: p(-1e12, 0.0), apex: p(0.0, 1e12), b: p(1e12, 0.0), w: 1e-12 }),
|
||||
analytic(bezier(&[p(0.0, 0.0), p(1e12, 0.0), p(0.0, 1e12), p(-1e12, 0.0)]).unwrap()),
|
||||
];
|
||||
let controls: Vec<Vec2> = (0..=9).map(|i| p(i as f64 * 1e11, if i % 2 == 0 { 1e12 } else { -1e12 })).collect();
|
||||
section.curves.push(analytic(bezier(&controls).unwrap()));
|
||||
section.validate_structure(TOL).unwrap();
|
||||
for curve in §ion.curves {
|
||||
for t in [0.0, 0.5, 1.0] {
|
||||
assert!(curve.geometry.point_at(t).is_finite());
|
||||
assert!(curve.geometry.derivative(t).is_finite());
|
||||
assert!(curve.geometry.derivatives(t, 4).is_some_and(|d| d.iter().all(|v| v.is_finite())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_serialization_preserves_accuracy_tags_and_plane_names() {
|
||||
let mut section = plate();
|
||||
section.curves[1].accuracy = CurveAccuracy::Bounded { max_deviation_mm: 0.01 };
|
||||
section.curves[2].accuracy = CurveAccuracy::Sampled { max_sample_deviation_mm: 0.0 };
|
||||
section.validate_structure(TOL).unwrap();
|
||||
let value = serde_json::to_value(§ion).unwrap();
|
||||
assert!(value["plane"].get("x_dir").is_some());
|
||||
assert!(value["plane"].get("y_dir").is_some());
|
||||
assert_eq!(value["curves"][0]["accuracy"], serde_json::json!({"kind": "analytic"}));
|
||||
assert_eq!(value["curves"][1]["accuracy"], serde_json::json!({"kind": "bounded", "max_deviation_mm": 0.01}));
|
||||
assert_eq!(value["curves"][2]["accuracy"], serde_json::json!({"kind": "sampled", "max_sample_deviation_mm": 0.0}));
|
||||
let restored: SectionGeometry = serde_json::from_value(value.clone()).unwrap();
|
||||
restored.validate_structure(TOL).unwrap();
|
||||
assert_eq!(section, restored);
|
||||
let mut unknown = value;
|
||||
unknown["certificate"] = serde_json::json!(true);
|
||||
assert!(serde_json::from_value::<SectionGeometry>(unknown).is_err());
|
||||
assert!(serde_json::from_value::<CurveAccuracy>(serde_json::json!({"kind": "bounded", "max_deviation_mm": 0.1, "exact": true})).is_err());
|
||||
assert!(serde_json::from_value::<CurveAccuracy>(serde_json::json!({"kind": "exact"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accuracy_values_are_validated_without_promoting_zero_sampled_error() {
|
||||
for value in [f64::NAN, f64::INFINITY, -0.01, 1e12 + 1.0] {
|
||||
for accuracy in [CurveAccuracy::Bounded { max_deviation_mm: value }, CurveAccuracy::Sampled { max_sample_deviation_mm: value }] {
|
||||
let mut section = plate();
|
||||
section.curves[0].accuracy = accuracy;
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("accuracy deviation")));
|
||||
}
|
||||
}
|
||||
for value in [0.0, 1e12] {
|
||||
let mut section = plate();
|
||||
section.curves[0].accuracy = CurveAccuracy::Sampled { max_sample_deviation_mm: value };
|
||||
section.validate_structure(TOL).unwrap();
|
||||
section.to_region(0, TOL).unwrap();
|
||||
assert_eq!(section.curves[0].accuracy, CurveAccuracy::Sampled { max_sample_deviation_mm: value });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_revalidates_mutated_data_and_does_not_trust_an_earlier_success() {
|
||||
let mut section = plate();
|
||||
section.validate_structure(TOL).unwrap();
|
||||
section.curves[0].geometry = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(f64::NAN, 0.0) };
|
||||
assert_eq!(section.to_loop(0, TOL), Err(Invalid("non-finite or excessive coordinate")));
|
||||
assert_eq!(section.to_region(0, TOL), Err(Invalid("non-finite or excessive coordinate")));
|
||||
let mut section = plate();
|
||||
section.to_region(0, TOL).unwrap();
|
||||
section.contacts = vec![Vec2::ZERO; MAX_ITEMS];
|
||||
assert_eq!(section.to_region(0, TOL), Err(Invalid("item budget")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_large_arc_conversion_rechecks_actual_connectivity_without_snapping() {
|
||||
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 1e12, start: 0.1, sweep: 1.0 };
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves = vec![analytic(arc), analytic(Seg2::Line { a: arc.start(), b: arc.end() })];
|
||||
section.wires.push(SectionWire { curves: vec![used(0, false), used(1, true)], closed: true });
|
||||
section.regions.push(SectionRegion { outer: 0, holes: vec![] });
|
||||
let before = section.clone();
|
||||
// Source endpoints coincide exactly, but (start + sweep) - sweep changes the reversed
|
||||
// arc's endpoint by more than this tolerance at this radius.
|
||||
section.validate_structure(1e-9).unwrap();
|
||||
assert!(arc.reversed().end().dist(arc.start()) > 1e-9);
|
||||
assert_eq!(section.to_loop(0, 1e-9), Err(Invalid("converted loop is not closed within tolerance")));
|
||||
assert_eq!(section.to_region(0, 1e-9), Err(Invalid("converted loop is not closed within tolerance")));
|
||||
assert_eq!(section, before);
|
||||
// A caller's explicit larger tolerance can accept the same result without moving endpoints.
|
||||
let converted = section.to_loop(0, 0.01).unwrap();
|
||||
assert_eq!(converted.segs[0], arc.reversed());
|
||||
assert!(converted.is_closed(0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connected_bow_tie_and_wrong_winding_are_not_certified_or_silently_repaired() {
|
||||
let p = Vec2::new;
|
||||
let loop_ = Loop2::polygon(&[p(0.0, 0.0), p(2.0, 2.0), p(0.0, 2.0), p(2.0, 0.0)]);
|
||||
let mut bow = empty(Plane::XY);
|
||||
bow.curves = loop_.segs.iter().copied().map(analytic).collect();
|
||||
bow.wires.push(SectionWire { curves: (0..4).map(|i| used(i, true)).collect(), closed: true });
|
||||
bow.regions.push(SectionRegion { outer: 0, holes: vec![] });
|
||||
bow.validate_structure(TOL).unwrap();
|
||||
assert_eq!(bow.to_loop(0, TOL).unwrap(), loop_);
|
||||
// A successful structural check does not establish a material region, including its winding.
|
||||
let mut reversed = plate();
|
||||
reversed.wires[0].curves.reverse();
|
||||
for use_ in &mut reversed.wires[0].curves {
|
||||
use_.forward = !use_.forward;
|
||||
}
|
||||
reversed.validate_structure(TOL).unwrap();
|
||||
let converted = reversed.to_region(0, TOL).unwrap();
|
||||
assert!((converted.outer.signed_area() + 6000.0).abs() < 1e-10);
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
//! Spline function spaces and the linear operators surface construction is built from: a clamped
|
||||
//! B-spline space ([`Space`]), interpolation in it with derivative (Hermite) conditions
|
||||
//! ([`Interpolant`]), carrying a spline into a larger space that contains it ([`embed`]: Bézier
|
||||
//! degree elevation and Boehm knot insertion, exact), and a monotone piecewise cubic through
|
||||
//! increasing data ([`Monotone`], for reparameterising curves).
|
||||
//!
|
||||
//! These are the pieces that make several splines *compatible* (one degree, one knot vector) so
|
||||
//! that their control points can be combined linearly: lofting, Coons and Gordon surfaces, and
|
||||
//! cross-boundary derivative conditions all reduce to them.
|
||||
//!
|
||||
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.) — §2.2–2.3 (basis functions and
|
||||
//! their derivatives), §5.2 (knot insertion), §5.5 (degree elevation; the Bézier case), §9.2.1
|
||||
//! (global interpolation, knots by averaging the parameters) and §9.2.2 (interpolation with
|
||||
//! end derivatives); Fritsch & Carlson 1980, "Monotone piecewise cubic interpolation" (SIAM J.
|
||||
//! Numer. Anal. 17), with the weighted harmonic-mean slopes of Fritsch & Butland 1984.
|
||||
//!
|
||||
//! Input is hostile: constructors check sizes and finiteness and return [`SplineError`]; no
|
||||
//! function here panics.
|
||||
|
||||
use crate::Vec3;
|
||||
use crate::bspline::{HPoint, MAX_SPLINE_DEGREE, Side, SplineError, SplineResult, check_knots, find_span, insert_knot, invalid, local_basis};
|
||||
|
||||
/// Most basis functions a space used for interpolation may have (its systems are solved densely).
|
||||
pub const MAX_SPACE_DIMENSION: usize = 2048;
|
||||
|
||||
/// Values a spline can carry: numbers and points (vectors).
|
||||
pub trait Linear: Copy {
|
||||
fn zero() -> Self;
|
||||
fn plus(self, o: Self) -> Self;
|
||||
fn times(self, s: f64) -> Self;
|
||||
/// The value as a homogeneous point of weight one (knot insertion works on those).
|
||||
fn to_h(self) -> HPoint;
|
||||
fn from_h(h: HPoint) -> Self;
|
||||
fn is_finite(self) -> bool;
|
||||
}
|
||||
|
||||
impl Linear for f64 {
|
||||
fn zero() -> Self {
|
||||
0.0
|
||||
}
|
||||
fn plus(self, o: Self) -> Self {
|
||||
self + o
|
||||
}
|
||||
fn times(self, s: f64) -> Self {
|
||||
self * s
|
||||
}
|
||||
fn to_h(self) -> HPoint {
|
||||
HPoint { x: self, y: 0.0, z: 0.0, w: 1.0 }
|
||||
}
|
||||
fn from_h(h: HPoint) -> Self {
|
||||
h.x
|
||||
}
|
||||
fn is_finite(self) -> bool {
|
||||
f64::is_finite(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Linear for Vec3 {
|
||||
fn zero() -> Self {
|
||||
Vec3::ZERO
|
||||
}
|
||||
fn plus(self, o: Self) -> Self {
|
||||
self + o
|
||||
}
|
||||
fn times(self, s: f64) -> Self {
|
||||
self * s
|
||||
}
|
||||
fn to_h(self) -> HPoint {
|
||||
HPoint::weighted(self, 1.0)
|
||||
}
|
||||
fn from_h(h: HPoint) -> Self {
|
||||
h.xyz()
|
||||
}
|
||||
fn is_finite(self) -> bool {
|
||||
Vec3::is_finite(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A clamped B-spline space: a degree and a knot vector whose first and last `degree + 1` knots
|
||||
/// are the domain's ends, interior knots repeated at most `degree` times (the space's splines
|
||||
/// are continuous).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Space {
|
||||
degree: usize,
|
||||
knots: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Space {
|
||||
pub fn new(degree: usize, knots: Vec<f64>) -> SplineResult<Space> {
|
||||
if degree == 0 || degree > MAX_SPLINE_DEGREE {
|
||||
return Err(SplineError::Unsupported(format!("degree {degree} (1 to {MAX_SPLINE_DEGREE})")));
|
||||
}
|
||||
check_knots(&knots)?;
|
||||
let n = knots.len().checked_sub(degree + 1).filter(|n| *n > degree).ok_or_else(|| SplineError::Invalid("too few knots".into()))?;
|
||||
if n > MAX_SPACE_DIMENSION {
|
||||
return Err(SplineError::TooLarge(format!("{n} basis functions (at most {MAX_SPACE_DIMENSION})")));
|
||||
}
|
||||
let (a, b) = (knots[0], knots[knots.len() - 1]);
|
||||
if !(a < b) || knots[..=degree].iter().any(|k| *k != a) || knots[n..].iter().any(|k| *k != b) {
|
||||
return invalid("the knot vector must be clamped on a non-empty domain");
|
||||
}
|
||||
let inner = &knots[degree + 1..n];
|
||||
let mut run = 0;
|
||||
for w in inner.windows(2) {
|
||||
run = if w[0] == w[1] { run + 1 } else { 0 };
|
||||
if run + 1 > degree {
|
||||
return invalid("an interior knot is repeated more often than the degree");
|
||||
}
|
||||
}
|
||||
if inner.first().is_some_and(|k| *k == a) || inner.last().is_some_and(|k| *k == b) {
|
||||
return invalid("an interior knot lies on a domain end");
|
||||
}
|
||||
Ok(Space { degree, knots })
|
||||
}
|
||||
|
||||
/// Polynomials of `degree` on `[a, b]` (one Bézier piece).
|
||||
pub fn polynomial(degree: usize, a: f64, b: f64) -> SplineResult<Space> {
|
||||
let mut knots = vec![a; degree + 1];
|
||||
knots.extend(std::iter::repeat_n(b, degree + 1));
|
||||
Space::new(degree, knots)
|
||||
}
|
||||
|
||||
/// Splines of `degree` with a simple knot at every interior break: `breaks` increase
|
||||
/// strictly, the first and last are the domain's ends.
|
||||
pub fn with_breaks(degree: usize, breaks: &[f64]) -> SplineResult<Space> {
|
||||
let (Some(&a), Some(&b)) = (breaks.first(), breaks.last()) else { return invalid("no breaks") };
|
||||
if breaks.len() > MAX_SPACE_DIMENSION || breaks.windows(2).any(|w| !(w[0] < w[1])) {
|
||||
return invalid("the breaks must increase");
|
||||
}
|
||||
let mut knots = vec![a; degree + 1];
|
||||
knots.extend(breaks.iter().skip(1).take(breaks.len().saturating_sub(2)).copied());
|
||||
knots.extend(std::iter::repeat_n(b, degree + 1));
|
||||
Space::new(degree, knots)
|
||||
}
|
||||
|
||||
pub fn degree(&self) -> usize {
|
||||
self.degree
|
||||
}
|
||||
|
||||
pub fn knots(&self) -> &[f64] {
|
||||
&self.knots
|
||||
}
|
||||
|
||||
/// The number of basis functions (control points of a spline in the space).
|
||||
pub fn count(&self) -> usize {
|
||||
self.knots.len() - self.degree - 1
|
||||
}
|
||||
|
||||
/// The domain `[a, b]`.
|
||||
pub fn domain(&self) -> [f64; 2] {
|
||||
[self.knots[0], self.knots[self.knots.len() - 1]]
|
||||
}
|
||||
|
||||
/// The `der`-th derivatives at `t` (clamped to the domain) of the basis functions that do not
|
||||
/// vanish there: the index of the first one and their values.
|
||||
pub fn basis(&self, t: f64, der: usize) -> SplineResult<(usize, Vec<f64>)> {
|
||||
let [a, b] = self.domain();
|
||||
if !t.is_finite() {
|
||||
return invalid("a parameter is not a finite number");
|
||||
}
|
||||
let t = t.clamp(a, b);
|
||||
let (p, n) = (self.degree, self.count());
|
||||
let span = find_span(&self.knots, p, n, t).ok_or_else(|| SplineError::Invalid(format!("{t} is outside the domain")))?;
|
||||
let side = if t >= b { Side::Left } else { Side::Right };
|
||||
let first = span - p;
|
||||
let values =
|
||||
(first..=span).map(|i| self.knots.get(i..i + p + 2).map_or(0.0, |k| local_basis(k, t, side, der).get(der).copied().unwrap_or(0.0)));
|
||||
Ok((first, values.collect()))
|
||||
}
|
||||
|
||||
/// The `der`-th derivative at `t` of the spline with control values `ctrl`.
|
||||
pub fn eval<T: Linear>(&self, ctrl: &[T], t: f64, der: usize) -> SplineResult<T> {
|
||||
if ctrl.len() != self.count() {
|
||||
return invalid(format!("{} control values for {} basis functions", ctrl.len(), self.count()));
|
||||
}
|
||||
let (first, values) = self.basis(t, der)?;
|
||||
Ok(values.iter().zip(ctrl.iter().skip(first)).fold(T::zero(), |acc, (n, c)| acc.plus(c.times(*n))))
|
||||
}
|
||||
|
||||
/// The splines of this space with `breaks` added as simple knots (those already knots are
|
||||
/// left as they are). The result contains this space.
|
||||
pub fn refined(&self, breaks: &[f64]) -> SplineResult<Space> {
|
||||
let [a, b] = self.domain();
|
||||
let mut knots = self.knots.clone();
|
||||
for &x in breaks {
|
||||
if x > a && x < b && !knots.contains(&x) {
|
||||
knots.push(x);
|
||||
}
|
||||
}
|
||||
knots.sort_by(f64::total_cmp);
|
||||
Space::new(self.degree, knots)
|
||||
}
|
||||
}
|
||||
|
||||
/// A condition on a spline: the value (`der` = 0) or a derivative at `t`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Cond {
|
||||
pub t: f64,
|
||||
pub der: usize,
|
||||
}
|
||||
|
||||
/// Interpolation in a space: the spline whose values and derivatives at the conditions are given
|
||||
/// (as many conditions as the space has basis functions). The system is factored once; each
|
||||
/// [`Interpolant::fit`] solves it for new data, so one interpolant serves many curves and the
|
||||
/// rows of a surface alike (the operator is linear).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Interpolant {
|
||||
space: Space,
|
||||
conds: Vec<Cond>,
|
||||
lu: Lu,
|
||||
}
|
||||
|
||||
impl Interpolant {
|
||||
pub fn new(space: Space, conds: Vec<Cond>) -> SplineResult<Interpolant> {
|
||||
let n = space.count();
|
||||
if conds.len() != n {
|
||||
return invalid(format!("{} conditions for {n} basis functions", conds.len()));
|
||||
}
|
||||
let mut a = vec![0.0; n * n];
|
||||
for (r, c) in conds.iter().enumerate() {
|
||||
let (first, values) = space.basis(c.t, c.der)?;
|
||||
for (k, v) in values.iter().enumerate() {
|
||||
if let Some(x) = a.get_mut(r * n + first + k) {
|
||||
*x = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
let lu = Lu::new(n, a).map_err(|_| SplineError::Invalid("the interpolation conditions do not determine a spline".into()))?;
|
||||
Ok(Interpolant { space, conds, lu })
|
||||
}
|
||||
|
||||
/// Interpolation by a single polynomial piece of the lowest degree that meets `conds` (degree
|
||||
/// one less than their number, at least one: a lone value is held constant), on `[a, b]`;
|
||||
/// with more than four conditions, a cubic spline whose interior knots average the
|
||||
/// conditions' parameters (taken as a sequence with one entry per condition, NURBS Book eq.
|
||||
/// 9.8 applied to that confluent sequence, which keeps every basis function's support over
|
||||
/// the condition it is matched with).
|
||||
pub fn lowest(conds: Vec<Cond>, a: f64, b: f64) -> SplineResult<Interpolant> {
|
||||
let mut conds = conds;
|
||||
if conds.iter().any(|c| !c.t.is_finite() || c.t < a || c.t > b) {
|
||||
return invalid("an interpolation parameter is outside the domain");
|
||||
}
|
||||
if conds.len() == 1
|
||||
&& let Some(c) = conds.first().copied()
|
||||
{
|
||||
// A constant: the value and a zero slope (the caller supplies zero data for it).
|
||||
conds.push(Cond { t: c.t, der: 1 });
|
||||
}
|
||||
let k = conds.len();
|
||||
if k <= 4 {
|
||||
return Interpolant::new(Space::polynomial(k.saturating_sub(1).max(1), a, b)?, conds);
|
||||
}
|
||||
let mut tau: Vec<f64> = conds.iter().map(|c| c.t).collect();
|
||||
tau.sort_by(f64::total_cmp);
|
||||
let mut breaks = vec![a];
|
||||
for j in 1..k - 3 {
|
||||
breaks.push((tau[j] + tau[j + 1] + tau[j + 2]) / 3.0);
|
||||
}
|
||||
breaks.push(b);
|
||||
if breaks.windows(2).any(|w| !(w[0] < w[1])) {
|
||||
return invalid("the interpolation parameters repeat too often");
|
||||
}
|
||||
Interpolant::new(Space::with_breaks(3, &breaks)?, conds)
|
||||
}
|
||||
|
||||
pub fn space(&self) -> &Space {
|
||||
&self.space
|
||||
}
|
||||
|
||||
pub fn conds(&self) -> &[Cond] {
|
||||
&self.conds
|
||||
}
|
||||
|
||||
/// The control values of the spline meeting the conditions with `data` (one per condition).
|
||||
pub fn fit<T: Linear>(&self, data: &[T]) -> SplineResult<Vec<T>> {
|
||||
if data.len() != self.conds.len() {
|
||||
return invalid(format!("{} values for {} conditions", data.len(), self.conds.len()));
|
||||
}
|
||||
let out = self.lu.solve(data);
|
||||
if out.iter().any(|x| !x.is_finite()) {
|
||||
return invalid("interpolation did not give finite control values");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// An LU factorisation with partial pivoting of a dense square matrix (row-major).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Lu {
|
||||
n: usize,
|
||||
a: Vec<f64>,
|
||||
piv: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Lu {
|
||||
fn new(n: usize, mut a: Vec<f64>) -> SplineResult<Lu> {
|
||||
if a.len() != n * n {
|
||||
return invalid("matrix size");
|
||||
}
|
||||
let scale = a.iter().fold(0.0f64, |m, x| m.max(x.abs()));
|
||||
if !(scale > 0.0 && scale.is_finite()) {
|
||||
return invalid("singular system");
|
||||
}
|
||||
let mut piv = vec![0; n];
|
||||
for k in 0..n {
|
||||
let (mut best, mut big) = (k, 0.0);
|
||||
for r in k..n {
|
||||
let x = a[r * n + k].abs();
|
||||
if x > big {
|
||||
(best, big) = (r, x);
|
||||
}
|
||||
}
|
||||
if !(big > scale * 1e-13) {
|
||||
return invalid("singular system");
|
||||
}
|
||||
piv[k] = best;
|
||||
if best != k {
|
||||
for c in 0..n {
|
||||
a.swap(k * n + c, best * n + c);
|
||||
}
|
||||
}
|
||||
let d = a[k * n + k];
|
||||
for r in k + 1..n {
|
||||
let f = a[r * n + k] / d;
|
||||
a[r * n + k] = f;
|
||||
if f != 0.0 {
|
||||
for c in k + 1..n {
|
||||
a[r * n + c] -= f * a[k * n + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Lu { n, a, piv })
|
||||
}
|
||||
|
||||
fn solve<T: Linear>(&self, b: &[T]) -> Vec<T> {
|
||||
let n = self.n;
|
||||
let mut x = b.to_vec();
|
||||
if x.len() != n {
|
||||
return x;
|
||||
}
|
||||
for (k, &p) in self.piv.iter().enumerate() {
|
||||
x.swap(k, p);
|
||||
}
|
||||
for r in 0..n {
|
||||
let row = &self.a[r * n..r * n + r];
|
||||
let acc = row.iter().zip(&x[..r]).fold(x[r], |acc, (l, xc)| acc.plus(xc.times(-l)));
|
||||
x[r] = acc;
|
||||
}
|
||||
for r in (0..n).rev() {
|
||||
let row = &self.a[r * n + r + 1..(r + 1) * n];
|
||||
let acc = row.iter().zip(&x[r + 1..]).fold(x[r], |acc, (u, xc)| acc.plus(xc.times(-u)));
|
||||
x[r] = acc.times(1.0 / self.a[r * n + r]);
|
||||
}
|
||||
x
|
||||
}
|
||||
}
|
||||
|
||||
/// The control values in `to` of the spline with control values `ctrl` in `from`, which `to`
|
||||
/// contains: the same domain, a degree at least as high, and every knot of `from` among those of
|
||||
/// `to` (with a polynomial `from` when the degree rises). Exact: Bézier degree elevation (NURBS
|
||||
/// Book §5.5) to the target degree, then Boehm knot insertion (§5.2) of the knots `to` adds.
|
||||
pub fn embed<T: Linear>(from: &Space, ctrl: &[T], to: &Space) -> SplineResult<Vec<T>> {
|
||||
if ctrl.len() != from.count() {
|
||||
return invalid("control values do not match the space");
|
||||
}
|
||||
let ([a0, b0], [a1, b1]) = (from.domain(), to.domain());
|
||||
if a0 != a1 || b0 != b1 {
|
||||
return invalid("the spaces' domains differ");
|
||||
}
|
||||
let mut p = from.degree();
|
||||
let mut knots = from.knots().to_vec();
|
||||
let mut pts: Vec<T> = ctrl.to_vec();
|
||||
if p > to.degree() {
|
||||
return invalid("the target space's degree is lower");
|
||||
}
|
||||
if p < to.degree() {
|
||||
if from.count() != p + 1 {
|
||||
return Err(SplineError::Unsupported("raising the degree of a spline with interior knots".into()));
|
||||
}
|
||||
while p < to.degree() {
|
||||
pts = elevate_bezier(&pts);
|
||||
p += 1;
|
||||
}
|
||||
knots = vec![a0; p + 1];
|
||||
knots.extend(std::iter::repeat_n(b0, p + 1));
|
||||
}
|
||||
// The knots `to` adds: a merge of the two sorted vectors.
|
||||
let target = to.knots();
|
||||
let mut missing = Vec::new();
|
||||
let mut j = 0;
|
||||
for &x in target {
|
||||
match knots.get(j) {
|
||||
Some(&k) if k == x => j += 1,
|
||||
Some(&k) if k < x => return invalid("a knot of the spline is not in the target space"),
|
||||
_ => missing.push(x),
|
||||
}
|
||||
}
|
||||
if j != knots.len() {
|
||||
return invalid("a knot of the spline is not in the target space");
|
||||
}
|
||||
let mut h: Vec<HPoint> = pts.iter().map(|x| x.to_h()).collect();
|
||||
for x in missing {
|
||||
(knots, h) = insert_knot(p, &knots, &h, x)?;
|
||||
}
|
||||
if knots.as_slice() != target {
|
||||
return invalid("knot insertion did not reach the target space");
|
||||
}
|
||||
Ok(h.into_iter().map(T::from_h).collect())
|
||||
}
|
||||
|
||||
/// A Bézier piece's control values one degree higher, the same polynomial (NURBS Book eq. 5.36):
|
||||
/// `Qᵢ = (i/(p+1))·Pᵢ₋₁ + (1 − i/(p+1))·Pᵢ`.
|
||||
fn elevate_bezier<T: Linear>(pts: &[T]) -> Vec<T> {
|
||||
let p = pts.len().saturating_sub(1);
|
||||
let at = |i: usize| pts.get(i).copied().unwrap_or_else(T::zero);
|
||||
(0..=p + 1)
|
||||
.map(|i| {
|
||||
let a = i as f64 / (p + 1) as f64;
|
||||
let prev = if i == 0 { T::zero() } else { at(i - 1).times(a) };
|
||||
prev.plus(at(i).times(1.0 - a))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A monotone piecewise cubic Hermite function through `(xₖ, yₖ)`, both strictly increasing
|
||||
/// (Fritsch & Carlson): slopes are the weighted harmonic means of the neighbouring secants
|
||||
/// (Fritsch & Butland), the secant at the ends. Its slope is positive everywhere, and through two
|
||||
/// points it is the straight line.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Monotone {
|
||||
x: Vec<f64>,
|
||||
y: Vec<f64>,
|
||||
m: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Monotone {
|
||||
pub fn new(x: Vec<f64>, y: Vec<f64>) -> SplineResult<Monotone> {
|
||||
let n = x.len();
|
||||
if n < 2 || y.len() != n {
|
||||
return invalid("a monotone cubic needs two points or more");
|
||||
}
|
||||
let increasing = |v: &[f64]| v.iter().all(|t| t.is_finite()) && v.windows(2).all(|w| w[0] < w[1]);
|
||||
if !increasing(&x) || !increasing(&y) {
|
||||
return invalid("the points of a monotone cubic must increase");
|
||||
}
|
||||
let h: Vec<f64> = x.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
let d: Vec<f64> = y.windows(2).zip(&h).map(|(w, h)| (w[1] - w[0]) / h).collect();
|
||||
let mut m = Vec::with_capacity(n);
|
||||
m.push(d[0]);
|
||||
for k in 1..n - 1 {
|
||||
let (w1, w2) = (2.0 * h[k] + h[k - 1], h[k] + 2.0 * h[k - 1]);
|
||||
m.push((w1 + w2) / (w1 / d[k - 1] + w2 / d[k]));
|
||||
}
|
||||
m.push(d[n - 2]);
|
||||
Ok(Monotone { x, y, m })
|
||||
}
|
||||
|
||||
/// The value and the first two derivatives at `t` (clamped to the points' range).
|
||||
pub fn eval(&self, t: f64) -> [f64; 3] {
|
||||
let n = self.x.len();
|
||||
let t = t.clamp(self.x[0], self.x[n - 1]);
|
||||
let k = self.x.partition_point(|x| *x <= t).saturating_sub(1).min(n - 2);
|
||||
let (x0, x1, y0, y1, m0, m1) = (self.x[k], self.x[k + 1], self.y[k], self.y[k + 1], self.m[k], self.m[k + 1]);
|
||||
let h = x1 - x0;
|
||||
let s = (t - x0) / h;
|
||||
let (s2, s3) = (s * s, s * s * s);
|
||||
let value = (2.0 * s3 - 3.0 * s2 + 1.0) * y0 + (s3 - 2.0 * s2 + s) * h * m0 + (-2.0 * s3 + 3.0 * s2) * y1 + (s3 - s2) * h * m1;
|
||||
let d1 = ((6.0 * s2 - 6.0 * s) * y0 + (3.0 * s2 - 4.0 * s + 1.0) * h * m0 + (-6.0 * s2 + 6.0 * s) * y1 + (3.0 * s2 - 2.0 * s) * h * m1) / h;
|
||||
let d2 = ((12.0 * s - 6.0) * y0 + (6.0 * s - 4.0) * h * m0 + (-12.0 * s + 6.0) * y1 + (6.0 * s - 2.0) * h * m1) / (h * h);
|
||||
[value, d1, d2]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-10
|
||||
}
|
||||
|
||||
/// Hermite interpolation reproduces a cubic from its values and end derivatives, and the
|
||||
/// lowest-degree interpolant through two values is the straight line.
|
||||
#[test]
|
||||
fn interpolation_meets_values_and_derivatives() {
|
||||
let f = |t: f64| 1.0 + 2.0 * t - 3.0 * t * t + 0.5 * t * t * t;
|
||||
let df = |t: f64| 2.0 - 6.0 * t + 1.5 * t * t;
|
||||
let d2f = |t: f64| -6.0 + 3.0 * t;
|
||||
let conds = vec![Cond { t: 0.0, der: 0 }, Cond { t: 0.0, der: 1 }, Cond { t: 0.0, der: 2 }, Cond { t: 2.0, der: 0 }];
|
||||
let it = Interpolant::lowest(conds, 0.0, 2.0).unwrap();
|
||||
let c = it.fit(&[f(0.0), df(0.0), d2f(0.0), f(2.0)]).unwrap();
|
||||
for k in 0..=10 {
|
||||
let t = k as f64 * 0.2;
|
||||
assert!(close(it.space().eval(&c, t, 0).unwrap(), f(t)), "{t}");
|
||||
}
|
||||
let line = Interpolant::lowest(vec![Cond { t: 0.0, der: 0 }, Cond { t: 1.0, der: 0 }], 0.0, 1.0).unwrap();
|
||||
let c = line.fit(&[2.0, 5.0]).unwrap();
|
||||
assert!(close(line.space().eval(&c, 0.25, 0).unwrap(), 2.75));
|
||||
let constant = Interpolant::lowest(vec![Cond { t: 0.3, der: 0 }], 0.0, 1.0).unwrap();
|
||||
let c = constant.fit(&[4.0, 0.0]).unwrap();
|
||||
assert!(close(constant.space().eval(&c, 0.9, 0).unwrap(), 4.0));
|
||||
}
|
||||
|
||||
/// Many conditions: a cubic spline with averaged knots through samples of a smooth function
|
||||
/// with derivative conditions at both ends (values exact at the samples).
|
||||
#[test]
|
||||
fn spline_interpolation_with_end_derivatives() {
|
||||
let f = |t: f64| (2.0 * t).sin();
|
||||
let df = |t: f64| 2.0 * (2.0 * t).cos();
|
||||
let ts: Vec<f64> = (0..=8).map(|k| k as f64 / 8.0).collect();
|
||||
let mut conds: Vec<Cond> = ts.iter().map(|&t| Cond { t, der: 0 }).collect();
|
||||
conds.push(Cond { t: 0.0, der: 1 });
|
||||
conds.push(Cond { t: 1.0, der: 1 });
|
||||
let it = Interpolant::lowest(conds, 0.0, 1.0).unwrap();
|
||||
let mut data: Vec<f64> = ts.iter().map(|&t| f(t)).collect();
|
||||
data.push(df(0.0));
|
||||
data.push(df(1.0));
|
||||
let c = it.fit(&data).unwrap();
|
||||
for &t in &ts {
|
||||
assert!(close(it.space().eval(&c, t, 0).unwrap(), f(t)));
|
||||
}
|
||||
assert!(close(it.space().eval(&c, 0.0, 1).unwrap(), df(0.0)));
|
||||
assert!(close(it.space().eval(&c, 1.0, 1).unwrap(), df(1.0)));
|
||||
assert!((it.space().eval(&c, 0.53, 0).unwrap() - f(0.53)).abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// Embedding is exact: a quadratic polynomial raised to a cubic with knots inserted, and a
|
||||
/// cubic spline refined, evaluate the same everywhere; a space that does not contain the
|
||||
/// spline is refused.
|
||||
#[test]
|
||||
fn embedding_keeps_the_spline() {
|
||||
let from = Space::polynomial(2, 0.0, 1.0).unwrap();
|
||||
let ctrl = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 2.0, 0.0), Vec3::new(3.0, -1.0, 1.0)];
|
||||
let to = Space::with_breaks(3, &[0.0, 0.2, 0.5, 0.7, 1.0]).unwrap();
|
||||
let out = embed(&from, &ctrl, &to).unwrap();
|
||||
let spline = Space::with_breaks(3, &[0.0, 0.5, 1.0]).unwrap();
|
||||
let sctrl: Vec<f64> = (0..spline.count()).map(|i| (i as f64 * 1.7).sin()).collect();
|
||||
let finer = spline.refined(&[0.25, 0.5, 0.8]).unwrap();
|
||||
let sout = embed(&spline, &sctrl, &finer).unwrap();
|
||||
for k in 0..=20 {
|
||||
let t = k as f64 / 20.0;
|
||||
assert!(from.eval(&ctrl, t, 0).unwrap().dist(to.eval(&out, t, 0).unwrap()) < 1e-12);
|
||||
assert!(close(spline.eval(&sctrl, t, 0).unwrap(), finer.eval(&sout, t, 0).unwrap()));
|
||||
}
|
||||
assert!(embed(&finer, &sout, &spline).is_err(), "a coarser space");
|
||||
assert!(embed(&spline, &sctrl, &Space::with_breaks(3, &[0.0, 0.4, 1.0]).unwrap()).is_err(), "a knot missing");
|
||||
assert!(embed(&spline, &sctrl, &Space::with_breaks(3, &[0.0, 0.5, 2.0]).unwrap()).is_err(), "another domain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotone_cubic_is_monotone_and_straight_through_two_points() {
|
||||
let m = Monotone::new(vec![0.0, 0.3, 0.5, 1.0], vec![0.0, 0.05, 0.9, 1.0]).unwrap();
|
||||
let mut last = -1.0;
|
||||
for k in 0..=200 {
|
||||
let [y, d, _] = m.eval(k as f64 / 200.0);
|
||||
assert!(y >= last && d >= 0.0, "{k}");
|
||||
last = y;
|
||||
}
|
||||
assert!(close(m.eval(0.3)[0], 0.05) && close(m.eval(0.5)[0], 0.9));
|
||||
let line = Monotone::new(vec![0.0, 1.0], vec![2.0, 4.0]).unwrap();
|
||||
assert!(close(line.eval(0.25)[0], 2.5) && close(line.eval(0.7)[1], 2.0) && close(line.eval(0.7)[2], 0.0));
|
||||
assert!(Monotone::new(vec![0.0, 0.0], vec![0.0, 1.0]).is_err());
|
||||
assert!(Monotone::new(vec![0.0, 1.0], vec![1.0, 0.0]).is_err());
|
||||
assert!(Monotone::new(vec![0.0, f64::NAN], vec![0.0, 1.0]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_spaces_are_refused() {
|
||||
assert!(Space::new(3, vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]).is_ok());
|
||||
assert!(Space::new(3, vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]).is_err(), "too few knots");
|
||||
assert!(Space::new(3, vec![0.0, 0.0, 0.0, 0.1, 1.0, 1.0, 1.0, 1.0]).is_err(), "not clamped");
|
||||
assert!(Space::new(1, vec![0.0, 0.0, 0.5, 0.5, 1.0, 1.0]).is_err(), "a discontinuous space");
|
||||
assert!(Space::new(0, vec![0.0, 1.0]).is_err());
|
||||
assert!(Space::with_breaks(3, &[0.0, 0.5, 0.5, 1.0]).is_err());
|
||||
assert!(Space::with_breaks(3, &[0.0, f64::NAN, 1.0]).is_err());
|
||||
let s = Space::with_breaks(3, &[0.0, 1.0]).unwrap();
|
||||
assert!(s.eval(&[1.0, 2.0], 0.5, 0).is_err(), "wrong control count");
|
||||
assert!(s.basis(f64::NAN, 0).is_err());
|
||||
// Repeated conditions do not determine a spline.
|
||||
assert!(Interpolant::new(s.clone(), vec![Cond { t: 0.5, der: 0 }; 4]).is_err());
|
||||
assert!(Interpolant::lowest(vec![Cond { t: 2.0, der: 0 }], 0.0, 1.0).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1494,6 +1494,33 @@ impl Body {
|
||||
shell_groups::meshes_meet(&a, &b, 2.0 * tol)
|
||||
}
|
||||
|
||||
/// The same contact test for several tools, reusing this unchanged body's
|
||||
/// fine mesh at each required tolerance. No contact threshold is relaxed.
|
||||
pub fn meets_each(&self, others: &[Body]) -> Result<Vec<bool>> {
|
||||
if others.len() > 1024 {
|
||||
return Err(KernelError::Invalid("too many contact tools".into()));
|
||||
}
|
||||
self.require_brep("contact test")?;
|
||||
let size = self.size();
|
||||
let mut meshes = std::collections::HashMap::new();
|
||||
let mut contacts = Vec::with_capacity(others.len());
|
||||
for other in others {
|
||||
other.require_brep("contact test")?;
|
||||
let tol = (size.max(other.size()) * 1e-4).max(1e-6);
|
||||
let key = tol.to_bits();
|
||||
if !meshes.contains_key(&key) {
|
||||
// Bound retained meshes even when hostile inputs request many tolerances.
|
||||
if meshes.len() == 16 {
|
||||
meshes.clear();
|
||||
}
|
||||
meshes.insert(key, self.tessellate(tol)?);
|
||||
}
|
||||
let mesh = meshes.get(&key).ok_or_else(|| KernelError::Internal("contact mesh disappeared".into()))?;
|
||||
contacts.push(shell_groups::meshes_meet(mesh, &other.tessellate(tol)?, 2.0 * tol)?);
|
||||
}
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
/// A boolean shortcut needs clearance larger than both shell mesh deflections.
|
||||
pub(crate) fn separated_lumps(&self) -> Result<Vec<Body>> {
|
||||
self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3))
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
//! surface — its tangent plane, its curvature, the change of that curvature — as the side's order
|
||||
//! asks for, with `crown` shaping the middle without touching a boundary condition.
|
||||
//!
|
||||
//! A patch through *three or four* boundaries is refused rather than built approximately. At a
|
||||
//! corner two sides prescribe the same entries of the control net from two different surfaces, and
|
||||
//! a tensor-product patch can hold both only where those two prescriptions already agree (one wall
|
||||
//! carrying every side, say). Where they do not — the general case — the requested order would
|
||||
//! A patch through *three or four* boundaries is refused here rather than built approximately. At
|
||||
//! a corner two sides prescribe the same entries of the control net from two different surfaces,
|
||||
//! and a tensor-product patch can hold both only where those two prescriptions already agree (one
|
||||
//! wall carrying every side, say). Where they do not — the general case — the requested order would
|
||||
//! silently drop near the corner, or the boundary curve would visibly leave its stations, and a
|
||||
//! Gregory-type corner (a rational blend of the sides' own data) is needed to hold them instead
|
||||
//! (issue #763). Everything this module refuses is an error, never a quietly worse surface.
|
||||
//! (issue #763). A loop of one sketch's curves is the case where they agree: it lies in the
|
||||
//! sketch's plane, and its flat Coons patch ([`crate::boundary_network`]) holds every order
|
||||
//! exactly; the network there refuses G1/G2 on two sides that meet at a corner for the same
|
||||
//! reason. Everything this module refuses is an error, never a quietly worse surface.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
//! Boundary surfaces (Surface › Boundary): one B-spline face through curves in one or two
|
||||
//! directions (`solidcraft_geom::network`: a loft between curves, a Coons patch inside four, a
|
||||
//! Gordon surface through a network), made from sketch curves, 3D sketch curves or a body's
|
||||
//! edges. A boundary curve that is a chain of open edges of a surface may carry a continuity
|
||||
//! condition with the face beside it: G1 (tangent) or G2 (curvature).
|
||||
//!
|
||||
//! The reusable pieces: [`CurveSource`] turns each kind of curve into a curve the network can
|
||||
//! measure, [`EdgeFaces`] gives the frame and second fundamental form of the faces along a chain
|
||||
//! of edges (what a G1 or G2 condition needs), and [`boundary_surface`] builds the face.
|
||||
//!
|
||||
//! The two-curve bridge with G0–G3 against a sketch's plane is [`crate::boundary`]; this is the
|
||||
//! general case: three or four sides, interior curves, 3D curves and edges.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use mt::{BoundedCurve, Cut, InnerSpace, ParametricCurve, ParametricSurface, SearchNearestParameter};
|
||||
use solidcraft_geom::bspline::{CurveN, NurbsSurface, SplineError};
|
||||
use solidcraft_geom::network::{self, CrossFace, FaceFrame, NetCurve, NetworkCurve, PointCurve, Tolerances};
|
||||
use solidcraft_geom::{Plane, Seg2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3, p3};
|
||||
use crate::bridge::Continuity;
|
||||
use crate::plane_fit::EdgeSampler;
|
||||
use crate::record::Rebuild;
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// How far apart curves of the two directions may pass and still cross (mm).
|
||||
pub const CROSS_TOLERANCE: f64 = 0.01;
|
||||
/// How far the surface may stray from its curves (mm).
|
||||
pub const FIT_TOLERANCE: f64 = 1e-4;
|
||||
/// Most points of a 3D sketch curve a boundary surface follows (longer ones are resampled).
|
||||
const MAX_WIRE_POINTS: usize = 400;
|
||||
/// Most points picking the edges of one curve.
|
||||
pub const MAX_EDGE_PICKS: usize = 10_000;
|
||||
/// Most sketch curves one curve of a boundary surface chains.
|
||||
pub const MAX_CHAIN_CURVES: usize = 1000;
|
||||
|
||||
/// The network's continuity for a curve's: G3 is refused (a network holds up to G2).
|
||||
fn net_order(c: &BoundaryCurve) -> Result<network::Continuity> {
|
||||
match c.continuity {
|
||||
Continuity::G0 => Ok(network::Continuity::G0),
|
||||
Continuity::G1 => Ok(network::Continuity::G1),
|
||||
Continuity::G2 => Ok(network::Continuity::G2),
|
||||
Continuity::G3 => Err(KernelError::Invalid(format!("not supported yet: G3 continuity on {} (a curve network holds up to G2)", c.label))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a curve of a boundary surface comes from.
|
||||
pub enum CurveSource<'a> {
|
||||
/// Sketch segments on a plane, each starting where the one before it ends.
|
||||
Sketch { plane: Plane, segs: Vec<Seg2> },
|
||||
/// A 3D sketch curve's points: followed smoothly through them (a cubic spline).
|
||||
Points(Vec<Vec3>),
|
||||
/// A chain of a body's edges, each picked by a point on it.
|
||||
Edges { body: &'a Body, at: Vec<Vec3> },
|
||||
/// A side collapsed to a point: the apex of a three-sided patch, where the sides beside it
|
||||
/// meet.
|
||||
Point(Vec3),
|
||||
}
|
||||
|
||||
/// A curve of a boundary surface: a name for messages, where it comes from, and (for edges of a
|
||||
/// surface) its continuity with the face beside it.
|
||||
pub struct BoundaryCurve<'a> {
|
||||
pub label: String,
|
||||
pub source: CurveSource<'a>,
|
||||
pub continuity: Continuity,
|
||||
}
|
||||
|
||||
/// One B-spline face through curves `u` (two or more; or one with a continuity condition, crossed
|
||||
/// by `v`) and `v` (none: a loft through `u`; two each way and no more: a Coons patch; more: a
|
||||
/// Gordon surface through the network). A surface body; its history has no operands (the face
|
||||
/// and its edges are new). Refused, naming the curves: curves that miss each other (by more than
|
||||
/// [`CROSS_TOLERANCE`]) or cross twice, curves out of order, closed curves, a continuity
|
||||
/// condition on anything but an open edge of a surface, or on curves of both directions.
|
||||
pub fn boundary_surface(u: &[BoundaryCurve], v: &[BoundaryCurve]) -> Result<Body> {
|
||||
for c in u.iter().chain(v) {
|
||||
if let CurveSource::Edges { body, .. } = &c.source {
|
||||
body.require_brep("boundary surface")?;
|
||||
}
|
||||
}
|
||||
guard("boundary surface", || {
|
||||
let mut corners: Vec<Vec3> = Vec::new();
|
||||
let mut to_net = |c: &BoundaryCurve| -> Result<NetworkCurve<'static>> {
|
||||
let (curve, face): (Box<dyn NetCurve>, Option<EdgeFaces>) = match &c.source {
|
||||
CurveSource::Sketch { plane, segs } => (Box::new(SketchChain::new(&c.label, plane, segs)?), None),
|
||||
CurveSource::Points(pts) => (Box::new(Polyline::new(&c.label, pts)?), None),
|
||||
CurveSource::Point(p) => {
|
||||
if !p.is_finite() {
|
||||
return Err(KernelError::Invalid(format!("{} is not a finite point", c.label)));
|
||||
}
|
||||
(Box::new(PointCurve(*p)), None)
|
||||
}
|
||||
CurveSource::Edges { body, at } => {
|
||||
if at.is_empty() || at.len() > MAX_EDGE_PICKS || at.iter().any(|p| !p.is_finite()) {
|
||||
return Err(KernelError::Invalid(format!("{}: pick 1 to {MAX_EDGE_PICKS} points on its edges", c.label)));
|
||||
}
|
||||
let (chain, closed) = crate::surfaces::picked_chain(body, at)?;
|
||||
if closed {
|
||||
return Err(KernelError::Invalid(format!("not supported yet: {} is a closed chain of edges", c.label)));
|
||||
}
|
||||
// The chain's inner vertices: the face's sides are split there, so that a
|
||||
// join shares the body's edges one for one.
|
||||
corners.extend(chain.iter().skip(1).map(|e| from_p3(e.front().point())));
|
||||
let face = match c.continuity {
|
||||
Continuity::G0 => None,
|
||||
_ => Some(EdgeFaces::new(&c.label, body, &chain)?),
|
||||
};
|
||||
(Box::new(EdgeChain::new(&c.label, &chain)?), face)
|
||||
}
|
||||
};
|
||||
if c.continuity != Continuity::G0 && face.is_none() {
|
||||
return Err(KernelError::Invalid(format!("{}: G1 and G2 apply only to a body's edges", c.label)));
|
||||
}
|
||||
let order = net_order(c)?;
|
||||
Ok(NetworkCurve { label: c.label.clone(), curve, condition: face.map(|f| (order, Box::new(f) as Box<dyn CrossFace>)) })
|
||||
};
|
||||
let nu: Vec<NetworkCurve> = u.iter().map(&mut to_net).collect::<Result<_>>()?;
|
||||
let nv: Vec<NetworkCurve> = v.iter().map(&mut to_net).collect::<Result<_>>()?;
|
||||
let surface = network::network_surface(nu, nv, Tolerances { cross: CROSS_TOLERANCE, fit: FIT_TOLERANCE }).map_err(|e| match e {
|
||||
SplineError::Unsupported(m) => KernelError::Invalid(format!("not supported yet: {m}")),
|
||||
SplineError::TooLarge(m) => KernelError::TooLarge(m),
|
||||
SplineError::Invalid(m) => KernelError::Invalid(m),
|
||||
})?;
|
||||
let face = face_of(&surface, &corners)?;
|
||||
let mut rec = Rebuild::default();
|
||||
rec.made(&face, Vec::new());
|
||||
crate::surfaces::body_of(vec![face], &[], &rec)
|
||||
})
|
||||
}
|
||||
|
||||
/// `made` (a boundary surface) sewn into `target` along the edges they share: one body, a solid
|
||||
/// when it closes the gap. Refused when they share no edge.
|
||||
pub fn boundary_join(target: &Body, made: &Body) -> Result<Body> {
|
||||
target.require_brep("join")?;
|
||||
let edges = |b: &Body| b.solid.edge_iter().map(|e| e.id()).collect::<HashSet<_>>().len();
|
||||
let joined = crate::surfaces::stitch(&[target, made], CROSS_TOLERANCE)?;
|
||||
if edges(&joined) == edges(target) + edges(made) {
|
||||
return Err(KernelError::Invalid("the surface shares no edge with the body it joins".into()));
|
||||
}
|
||||
Ok(joined)
|
||||
}
|
||||
|
||||
/// Sketch segments on a plane, run end to end by arc length.
|
||||
struct SketchChain {
|
||||
plane: Plane,
|
||||
segs: Vec<Seg2>,
|
||||
/// Cumulative length at the start of each segment, and the total.
|
||||
starts: Vec<f64>,
|
||||
total: f64,
|
||||
}
|
||||
|
||||
impl SketchChain {
|
||||
fn new(label: &str, plane: &Plane, segs: &[Seg2]) -> Result<SketchChain> {
|
||||
if segs.is_empty() || segs.len() > MAX_CHAIN_CURVES {
|
||||
return Err(KernelError::Invalid(format!("{label} has no curves, or too many")));
|
||||
}
|
||||
if segs.windows(2).any(|w| w[0].end().dist(w[1].start()) > 1e-6) {
|
||||
return Err(KernelError::Invalid(format!("{label}: its curves don't meet end to end")));
|
||||
}
|
||||
let mut starts = Vec::with_capacity(segs.len());
|
||||
let mut total = 0.0;
|
||||
for s in segs {
|
||||
starts.push(total);
|
||||
total += s.length();
|
||||
}
|
||||
if !(total > 0.0 && total.is_finite()) {
|
||||
return Err(KernelError::Invalid(format!("{label} has no length")));
|
||||
}
|
||||
Ok(SketchChain { plane: *plane, segs: segs.to_vec(), starts, total })
|
||||
}
|
||||
}
|
||||
|
||||
impl NetCurve for SketchChain {
|
||||
fn point(&mut self, t: f64) -> Option<Vec3> {
|
||||
let at = t.clamp(0.0, 1.0) * self.total;
|
||||
let k = self.starts.partition_point(|s| *s <= at).saturating_sub(1);
|
||||
let (seg, start) = (self.segs.get(k)?, self.starts.get(k)?);
|
||||
let len = seg.length();
|
||||
let local = if len > 0.0 { ((at - start) / len).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let p = self.plane.to_world(seg.point_at(local));
|
||||
p.is_finite().then_some(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3D polyline followed smoothly: a cubic through its points by chord length (fewer points, a
|
||||
/// lower degree; a long one resampled first).
|
||||
struct Polyline {
|
||||
curve: CurveN<3>,
|
||||
end: f64,
|
||||
}
|
||||
|
||||
impl Polyline {
|
||||
fn new(label: &str, pts: &[Vec3]) -> Result<Polyline> {
|
||||
let mut clean: Vec<Vec3> = Vec::with_capacity(pts.len());
|
||||
for p in pts {
|
||||
if !p.is_finite() {
|
||||
return Err(KernelError::Invalid(format!("{label} has a point that is not a finite number")));
|
||||
}
|
||||
if clean.last().is_none_or(|q: &Vec3| q.dist(*p) > 1e-9) {
|
||||
clean.push(*p);
|
||||
}
|
||||
}
|
||||
let pts = resample(&clean, MAX_WIRE_POINTS);
|
||||
if pts.len() < 2 {
|
||||
return Err(KernelError::Invalid(format!("{label} has no length")));
|
||||
}
|
||||
let mut params = vec![0.0];
|
||||
for w in pts.windows(2) {
|
||||
params.push(params.last().copied().unwrap_or(0.0) + w[0].dist(w[1]));
|
||||
}
|
||||
let end = params.last().copied().unwrap_or(0.0);
|
||||
let data: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect();
|
||||
let curve = CurveN::interpolate(¶ms, &data, (pts.len() - 1).min(3))
|
||||
.map_err(|e| KernelError::Invalid(format!("{label} can't be followed smoothly: {e}")))?;
|
||||
Ok(Polyline { curve, end })
|
||||
}
|
||||
}
|
||||
|
||||
impl NetCurve for Polyline {
|
||||
fn point(&mut self, t: f64) -> Option<Vec3> {
|
||||
let ([x, y, z], _) = self.curve.eval(t.clamp(0.0, 1.0) * self.end)?;
|
||||
Some(Vec3::new(x, y, z))
|
||||
}
|
||||
}
|
||||
|
||||
/// At most `max` points evenly along a polyline by arc length, its ends kept.
|
||||
fn resample(pts: &[Vec3], max: usize) -> Vec<Vec3> {
|
||||
if pts.len() <= max || max < 2 {
|
||||
return pts.to_vec();
|
||||
}
|
||||
let mut cum = vec![0.0];
|
||||
for w in pts.windows(2) {
|
||||
cum.push(cum.last().copied().unwrap_or(0.0) + w[0].dist(w[1]));
|
||||
}
|
||||
let total = cum.last().copied().unwrap_or(0.0);
|
||||
(0..max)
|
||||
.filter_map(|k| {
|
||||
let at = total * k as f64 / (max - 1) as f64;
|
||||
let i = cum.partition_point(|c| *c <= at).saturating_sub(1).min(pts.len().saturating_sub(2));
|
||||
let (c0, c1) = (cum.get(i)?, cum.get(i + 1)?);
|
||||
let w = if c1 > c0 { ((at - c0) / (c1 - c0)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
Some(pts.get(i)?.lerp(*pts.get(i + 1)?, w))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A chain of edges (each turned to run along it), by their lengths.
|
||||
struct EdgeChain {
|
||||
edges: Vec<EdgeSampler>,
|
||||
starts: Vec<f64>,
|
||||
total: f64,
|
||||
}
|
||||
|
||||
impl EdgeChain {
|
||||
fn new(label: &str, chain: &[mt::Edge]) -> Result<EdgeChain> {
|
||||
let mut edges = Vec::with_capacity(chain.len());
|
||||
let mut starts = Vec::with_capacity(chain.len());
|
||||
let mut total = 0.0;
|
||||
for e in chain {
|
||||
let mut s = EdgeSampler::new(e).ok_or_else(|| KernelError::Invalid(format!("{label}: an edge has no parameter range")))?;
|
||||
let mut len = 0.0;
|
||||
let mut last: Option<Vec3> = None;
|
||||
for k in 0..=16 {
|
||||
let p = s.at(k as f64 / 16.0).ok_or_else(|| KernelError::Invalid(format!("{label}: an edge can't be evaluated")))?;
|
||||
if let Some(q) = last {
|
||||
len += p.dist(q);
|
||||
}
|
||||
last = Some(p);
|
||||
}
|
||||
starts.push(total);
|
||||
total += len;
|
||||
edges.push(s);
|
||||
}
|
||||
if !(total > 0.0 && total.is_finite()) {
|
||||
return Err(KernelError::Invalid(format!("{label} has no length")));
|
||||
}
|
||||
Ok(EdgeChain { edges, starts, total })
|
||||
}
|
||||
}
|
||||
|
||||
impl NetCurve for EdgeChain {
|
||||
fn point(&mut self, t: f64) -> Option<Vec3> {
|
||||
let at = t.clamp(0.0, 1.0) * self.total;
|
||||
let k = self.starts.partition_point(|s| *s <= at).saturating_sub(1);
|
||||
let start = *self.starts.get(k)?;
|
||||
let end = self.starts.get(k + 1).copied().unwrap_or(self.total);
|
||||
let local = if end > start { ((at - start) / (end - start)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
self.edges.get_mut(k)?.at(local)
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces along a chain of edges, each edge an open edge of one face of its body: their
|
||||
/// frames and second fundamental forms where a boundary surface meets them (G1 and G2
|
||||
/// conditions).
|
||||
pub(crate) struct EdgeFaces {
|
||||
sides: Vec<EdgeSide>,
|
||||
}
|
||||
|
||||
/// One edge of the chain: points along it (to find the edge nearest a point), the surface of
|
||||
/// its face, whether the face's boundary runs along the chain (`along`), and the surface
|
||||
/// parameters found last (the next search starts there).
|
||||
struct EdgeSide {
|
||||
pts: Vec<Vec3>,
|
||||
surface: mt::Surface,
|
||||
along: bool,
|
||||
hint: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl EdgeFaces {
|
||||
pub(crate) fn new(label: &str, body: &Body, chain: &[mt::Edge]) -> Result<EdgeFaces> {
|
||||
let mut sides = Vec::with_capacity(chain.len());
|
||||
for e in chain {
|
||||
let mut faces = body.solid.face_iter().filter(|f| f.boundaries().iter().flatten().any(|x| x.id() == e.id()));
|
||||
let (Some(face), None) = (faces.next(), faces.next()) else {
|
||||
return Err(KernelError::Invalid(format!(
|
||||
"{label}: G1 and G2 need an open edge of a surface (an edge with a face on one side only)"
|
||||
)));
|
||||
};
|
||||
// How the face's own (absolute) boundary runs this edge.
|
||||
let in_wire = face.absolute_boundaries().iter().flatten().find(|x| x.id() == e.id()).cloned();
|
||||
let in_wire = in_wire.ok_or_else(|| KernelError::Failed("boundary surface: an edge's face".into()))?;
|
||||
let along = in_wire.front().id() == e.front().id();
|
||||
let mut sampler = EdgeSampler::new(e).ok_or_else(|| KernelError::Invalid(format!("{label}: an edge has no parameter range")))?;
|
||||
let pts = (0..=16).map(|k| sampler.at(k as f64 / 16.0)).collect::<Option<Vec<_>>>();
|
||||
let pts = pts.ok_or_else(|| KernelError::Invalid(format!("{label}: an edge can't be evaluated")))?;
|
||||
sides.push(EdgeSide { pts, surface: face.surface(), along, hint: None });
|
||||
}
|
||||
Ok(EdgeFaces { sides })
|
||||
}
|
||||
}
|
||||
|
||||
impl CrossFace for EdgeFaces {
|
||||
fn frame(&mut self, at: Vec3, tangent: Vec3) -> Option<FaceFrame> {
|
||||
// The edge nearest the point, and the chain's direction along it there.
|
||||
let near = |s: &EdgeSide| s.pts.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||||
let k = (0..self.sides.len()).min_by(|a, b| near(&self.sides[*a]).total_cmp(&near(&self.sides[*b])))?;
|
||||
let side = self.sides.get_mut(k)?;
|
||||
let seg = side.pts.windows(2).min_by(|a, b| at.dist_to_segment(a[0], a[1]).total_cmp(&at.dist_to_segment(b[0], b[1])))?;
|
||||
let chain_dir = seg[1] - seg[0];
|
||||
let s = &side.surface;
|
||||
let (u, v) = s.search_nearest_parameter(p3(at), side.hint, 100).or_else(|| s.search_nearest_parameter(p3(at), None, 100))?;
|
||||
side.hint = Some((u, v));
|
||||
let (su, sv) = (s.uder(u, v), s.vder(u, v));
|
||||
let n = su.cross(sv);
|
||||
if !(n.magnitude2() > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let n = n.normalize();
|
||||
let normal = Vec3::new(n.x, n.y, n.z);
|
||||
// The face's boundary runs counter-clockwise about its surface's normal, so the face lies
|
||||
// to the left of it: outward is the boundary's direction × the normal.
|
||||
let wire = if side.along { chain_dir } else { -chain_dir };
|
||||
let outward = wire.cross(normal).normalized()?;
|
||||
// The second fundamental form, in the basis of the tangent asked about and outward.
|
||||
let t = (tangent - normal * tangent.dot(normal)).normalized()?;
|
||||
let b = (outward - t * outward.dot(t)).normalized()?;
|
||||
let (e, f, g) = (su.dot(su), su.dot(sv), sv.dot(sv));
|
||||
let det = e * g - f * f;
|
||||
if !(det.abs() > 1e-24) {
|
||||
return None;
|
||||
}
|
||||
// II(w, w) = L·α² + 2M·αβ + N·β² for w = α·S_u + β·S_v, with L, M, N the surface's
|
||||
// second derivatives along its unit normal (do Carmo, §3-3): exact, rational surfaces
|
||||
// included, and with nothing sampled beyond the edge, where the face's domain ends.
|
||||
let nv = mt::Vector3::new(normal.x, normal.y, normal.z);
|
||||
let (l, m, nn) = (s.uuder(u, v).dot(nv), s.uvder(u, v).dot(nv), s.vvder(u, v).dot(nv));
|
||||
let ii = |w: Vec3| -> Option<f64> {
|
||||
let wv = mt::Vector3::new(w.x, w.y, w.z);
|
||||
let (p, q) = (wv.dot(su), wv.dot(sv));
|
||||
let (alpha, beta) = ((p * g - q * f) / det, (q * e - p * f) / det);
|
||||
let out = l * alpha * alpha + 2.0 * m * alpha * beta + nn * beta * beta;
|
||||
out.is_finite().then_some(out)
|
||||
};
|
||||
let (tt, bb) = (ii(t)?, ii(b)?);
|
||||
let tb = (ii(t + b)? - tt - bb) / 2.0;
|
||||
let form = [tt, tb, bb];
|
||||
form.iter().all(|x| x.is_finite()).then_some(FaceFrame { normal, outward: b, form })
|
||||
}
|
||||
}
|
||||
|
||||
/// The face of a network surface: its four sides from the surface's boundary rows, each split
|
||||
/// at the `corners` that lie on it (inner vertices of edge chains). Refused when a side shrinks to
|
||||
/// a point.
|
||||
fn face_of(s: &NurbsSurface, corners: &[Vec3]) -> Result<mt::Face> {
|
||||
let fail = |m: &str| KernelError::Failed(format!("boundary surface: {m}"));
|
||||
let [nu, nv] = s.count();
|
||||
let [ku, kv] = s.knots();
|
||||
let rows: Vec<Vec<mt::Point3>> = (0..nu).map(|i| (0..nv).map(|j| p3(s.point(i, j).xyz())).collect()).collect();
|
||||
let (uk, vk) = (mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone()));
|
||||
let surface = mt::BSplineSurface::try_new((uk.clone(), vk.clone()), rows.clone()).map_err(|e| fail(&e.to_string()))?;
|
||||
let row = |pts: Vec<mt::Point3>, k: &mt::KnotVec| mt::BSplineCurve::try_new(k.clone(), pts).map_err(|e| fail(&e.to_string()));
|
||||
let (Some(first), Some(last)) = (rows.first(), rows.last()) else { return Err(fail("control net")) };
|
||||
let bottom = row(rows.iter().filter_map(|r| r.first().copied()).collect(), &uk)?;
|
||||
let top = row(rows.iter().filter_map(|r| r.last().copied()).collect(), &uk)?;
|
||||
let left = row(first.clone(), &vk)?;
|
||||
let right = row(last.clone(), &vk)?;
|
||||
let corner = |c: &mt::BSplineCurve<mt::Point3>, end: bool| {
|
||||
let (t0, t1) = c.range_tuple();
|
||||
c.subs(if end { t1 } else { t0 })
|
||||
};
|
||||
let (c00, c10, c11, c01) = (corner(&bottom, false), corner(&bottom, true), corner(&top, true), corner(&top, false));
|
||||
let size = [c00, c10, c11, c01].iter().map(|p| from_p3(*p).dist(from_p3(c00))).fold(0.0, f64::max).max(1.0);
|
||||
// A side collapsed to a point (a three-sided patch) is left out of the loop: the two sides
|
||||
// beside it meet at one vertex there.
|
||||
let sides = [(c00, c10), (c10, c11), (c01, c11), (c00, c01)];
|
||||
let collapsed: Vec<bool> = sides.iter().map(|(a, b)| from_p3(*a).dist(from_p3(*b)) <= size * 1e-9).collect();
|
||||
match collapsed.iter().filter(|c| **c).count() {
|
||||
0 | 1 => {}
|
||||
_ => return Err(KernelError::Invalid("not supported yet: a boundary surface with more than one side shrunk to a point".into())),
|
||||
}
|
||||
let mut verts: Vec<mt::Vertex> = Vec::with_capacity(4);
|
||||
for c in [c00, c10, c11, c01] {
|
||||
let same = verts.iter().find(|v| from_p3(v.point()).dist(from_p3(c)) <= size * 1e-9).cloned();
|
||||
verts.push(same.unwrap_or_else(|| mt::Vertex::new(c)));
|
||||
}
|
||||
let (Some(v00), Some(v10), Some(v11), Some(v01)) = (verts.first(), verts.get(1), verts.get(2), verts.get(3)) else {
|
||||
return Err(fail("corners"));
|
||||
};
|
||||
let is = |k: usize| collapsed.get(k).copied().unwrap_or(false);
|
||||
let mut wire: Vec<mt::Edge> = Vec::new();
|
||||
if !is(0) {
|
||||
wire.extend(side_edges(bottom, v00, v10, corners));
|
||||
}
|
||||
if !is(1) {
|
||||
wire.extend(side_edges(right, v10, v11, corners));
|
||||
}
|
||||
if !is(2) {
|
||||
wire.extend(side_edges(top, v01, v11, corners).into_iter().rev().map(|e| e.inverse()));
|
||||
}
|
||||
if !is(3) {
|
||||
wire.extend(side_edges(left, v00, v01, corners).into_iter().rev().map(|e| e.inverse()));
|
||||
}
|
||||
// u along the bottom, v up the left: counter-clockwise in (u, v).
|
||||
mt::Face::try_new(vec![wire.into()], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))
|
||||
}
|
||||
|
||||
/// The edges of one side from `a` to `b`, split at the points of `corners` that lie on it
|
||||
/// (within [`CROSS_TOLERANCE`]) strictly between its ends.
|
||||
fn side_edges(mut curve: mt::BSplineCurve<mt::Point3>, a: &mt::Vertex, b: &mt::Vertex, corners: &[Vec3]) -> Vec<mt::Edge> {
|
||||
let (t0, t1) = curve.range_tuple();
|
||||
let margin = (t1 - t0) * 1e-6;
|
||||
let mut cuts: Vec<f64> = corners
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let t = curve.search_nearest_parameter(p3(*c), None, 100)?;
|
||||
(t > t0 + margin && t < t1 - margin && from_p3(curve.subs(t)).dist(*c) <= CROSS_TOLERANCE).then_some(t)
|
||||
})
|
||||
.collect();
|
||||
cuts.sort_by(f64::total_cmp);
|
||||
cuts.dedup_by(|x, y| (*x - *y).abs() <= margin);
|
||||
let mut edges = Vec::with_capacity(cuts.len() + 1);
|
||||
let mut from = a.clone();
|
||||
// Cut from the far end back, so the earlier parameters stay on the part kept.
|
||||
let mut tails = Vec::with_capacity(cuts.len());
|
||||
for &t in cuts.iter().rev() {
|
||||
tails.push(curve.cut(t));
|
||||
}
|
||||
let mut pieces = vec![curve];
|
||||
pieces.extend(tails.into_iter().rev());
|
||||
let count = pieces.len();
|
||||
for (k, piece) in pieces.into_iter().enumerate() {
|
||||
let to = if k + 1 == count { b.clone() } else { mt::Vertex::new(piece.subs(piece.range_tuple().1)) };
|
||||
edges.push(mt::Edge::new(&from, &to, mt::Curve::BSplineCurve(piece)));
|
||||
from = to;
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::patch_region;
|
||||
use mt::ParametricSurface3D;
|
||||
use solidcraft_geom::{Loop2, Region2, Vec2};
|
||||
|
||||
fn sketch(label: &str, plane: Plane, segs: Vec<Seg2>) -> BoundaryCurve<'static> {
|
||||
BoundaryCurve { label: label.into(), source: CurveSource::Sketch { plane, segs }, continuity: Continuity::G0 }
|
||||
}
|
||||
|
||||
fn line3(label: &str, a: Vec3, b: Vec3) -> BoundaryCurve<'static> {
|
||||
BoundaryCurve { label: label.into(), source: CurveSource::Points(vec![a, b]), continuity: Continuity::G0 }
|
||||
}
|
||||
|
||||
fn face_surface(b: &Body) -> mt::Surface {
|
||||
b.solid.face_iter().next().unwrap().surface()
|
||||
}
|
||||
|
||||
/// The flat sheet y ∈ [−10, 0], x ∈ [0, 10] in z = 0, its edge along y = 0 open.
|
||||
fn sheet() -> Body {
|
||||
let sq = Loop2::polygon(&[Vec2::new(0.0, -10.0), Vec2::new(10.0, -10.0), Vec2::new(10.0, 0.0), Vec2::new(0.0, 0.0)]);
|
||||
patch_region(&Plane::XY, &Region2 { outer: sq, holes: vec![] }).unwrap()
|
||||
}
|
||||
|
||||
/// A Coons patch of four lines bounding the hyperbolic paraboloid z = x·y/10: every point of
|
||||
/// the face lies on it, and its area is the paraboloid's over the square.
|
||||
#[test]
|
||||
fn coons_patch_of_lines_is_a_hyperbolic_paraboloid() {
|
||||
let p = |x: f64, y: f64| Vec3::new(x, y, x * y / 10.0);
|
||||
let body = boundary_surface(
|
||||
&[line3("u0", p(0.0, 0.0), p(10.0, 0.0)), line3("u1", p(0.0, 10.0), p(10.0, 10.0))],
|
||||
&[line3("v0", p(0.0, 0.0), p(0.0, 10.0)), line3("v1", p(10.0, 0.0), p(10.0, 10.0))],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(body.is_surface() && body.face_count() == 1);
|
||||
let s = face_surface(&body);
|
||||
for i in 0..=10 {
|
||||
for j in 0..=10 {
|
||||
let q = from_p3(s.subs(i as f64 / 10.0, j as f64 / 10.0));
|
||||
assert!((q.z - q.x * q.y / 10.0).abs() < 1e-9, "{q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch curves: a patch inside two arcs and two lines on one plane lies in that plane.
|
||||
#[test]
|
||||
fn coplanar_sketch_curves_give_a_plane() {
|
||||
use std::f64::consts::PI;
|
||||
// Two half-discs' arcs over [0, 10] (radius 5) facing out, joined by the lines x = 0, 10.
|
||||
let low = Seg2::Arc { center: Vec2::new(5.0, 0.0), radius: 5.0, start: PI, sweep: PI };
|
||||
let high = Seg2::Arc { center: Vec2::new(5.0, 8.0), radius: 5.0, start: PI, sweep: -PI };
|
||||
let left = Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(0.0, 8.0) };
|
||||
let right = Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 8.0) };
|
||||
let pl = Plane::XY.offset(3.0);
|
||||
let body = boundary_surface(
|
||||
&[sketch("low", pl, vec![low]), sketch("high", pl, vec![high])],
|
||||
&[sketch("left", pl, vec![left]), sketch("right", pl, vec![right])],
|
||||
)
|
||||
.unwrap();
|
||||
let s = face_surface(&body);
|
||||
for i in 0..=8 {
|
||||
for j in 0..=8 {
|
||||
assert!((s.subs(i as f64 / 8.0, j as f64 / 8.0).z - 3.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stadium: two half-circle arcs (radius 5) bulging out of the 10 × 8 rectangle between two
|
||||
/// lines. Each arc meets its lines at 180°, so the patch is singular at its corners, and the
|
||||
/// reference mesher's walk round the boundary used to run from such a corner onto the
|
||||
/// surface's extrapolation past its domain (a planar spline passes through every point of its
|
||||
/// plane there): the face measured 289 mm² for its 158.5. The vendored mesher's bounded
|
||||
/// projection (`nearest_projection`, vendor/README.md) keeps the walk on the domain. Its area
|
||||
/// is the stadium's, 80 + 25π, by the faces, the measuring mesh and the display mesh.
|
||||
#[test]
|
||||
fn a_patch_with_straight_corners_measures_its_own_area() {
|
||||
use std::f64::consts::PI;
|
||||
let low = Seg2::Arc { center: Vec2::new(5.0, 0.0), radius: 5.0, start: PI, sweep: PI };
|
||||
let high = Seg2::Arc { center: Vec2::new(5.0, 8.0), radius: 5.0, start: PI, sweep: -PI };
|
||||
let left = Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(0.0, 8.0) };
|
||||
let right = Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 8.0) };
|
||||
let pl = Plane::XY.offset(3.0);
|
||||
let body = boundary_surface(
|
||||
&[sketch("low", pl, vec![low]), sketch("high", pl, vec![high])],
|
||||
&[sketch("left", pl, vec![left]), sketch("right", pl, vec![right])],
|
||||
)
|
||||
.unwrap();
|
||||
let want = 80.0 + 25.0 * PI;
|
||||
// Chords of the arcs at 0.01 mm sag cut off up to (2/3)·sag·length ≈ 0.21 mm².
|
||||
let near = |a: f64| (a - want).abs() < 0.25;
|
||||
let faces = body.faces(0.01).unwrap();
|
||||
assert!(faces.len() == 1 && near(faces[0].area), "faces: {faces:?}");
|
||||
let tess = body.tessellate(0.01).unwrap().measure().area;
|
||||
assert!(near(tess), "measuring mesh: {tess}");
|
||||
let display = body.display_mesh(0.01).unwrap().measure().area;
|
||||
assert!(near(display), "display mesh: {display}");
|
||||
}
|
||||
|
||||
/// Three sides: a quarter disc of radius 10 (two radii and the arc) is one face with three
|
||||
/// edges, the collapsed side left out of its loop, and measures 25π by its faces and both
|
||||
/// meshes.
|
||||
#[test]
|
||||
fn a_three_sided_patch_is_one_face_with_three_edges() {
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
let r = 10.0;
|
||||
let arc = Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: r, start: 0.0, sweep: FRAC_PI_2 };
|
||||
let x = Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(r, 0.0) };
|
||||
let y = Seg2::Line { a: Vec2::new(0.0, r), b: Vec2::new(0.0, 0.0) };
|
||||
let pl = Plane::XY;
|
||||
let apex = BoundaryCurve { label: "centre".into(), source: CurveSource::Point(Vec3::ZERO), continuity: Continuity::G0 };
|
||||
let body = boundary_surface(&[sketch("arc", pl, vec![arc]), apex], &[sketch("x", pl, vec![x]), sketch("y", pl, vec![y])]).unwrap();
|
||||
assert!(body.is_surface() && body.face_count() == 1);
|
||||
let edges: std::collections::HashSet<_> = body.solid.edge_iter().map(|e| e.id()).collect();
|
||||
assert_eq!(edges.len(), 3, "the collapsed side is no edge");
|
||||
let want = PI * r * r / 4.0;
|
||||
let near = |a: f64| (a - want).abs() < 0.2;
|
||||
let faces = body.faces(0.01).unwrap();
|
||||
assert!(near(faces[0].area), "faces: {faces:?}");
|
||||
let tess = body.tessellate(0.01).unwrap().measure().area;
|
||||
assert!(near(tess), "measuring mesh: {tess}");
|
||||
let display = body.display_mesh(0.01).unwrap().measure().area;
|
||||
assert!(near(display), "display mesh: {display}");
|
||||
// Two collapsed sides are no patch.
|
||||
let p = |l: &str, q: Vec3| BoundaryCurve { label: l.into(), source: CurveSource::Point(q), continuity: Continuity::G0 };
|
||||
assert!(
|
||||
boundary_surface(&[sketch("arc", pl, vec![arc]), p("o", Vec3::ZERO)], &[p("a", Vec3::new(r, 0.0, 0.0)), sketch("y", pl, vec![y])])
|
||||
.is_err()
|
||||
);
|
||||
assert!(boundary_surface(&[sketch("arc", pl, vec![arc]), p("nan", Vec3::new(f64::NAN, 0.0, 0.0))], &[]).is_err());
|
||||
}
|
||||
|
||||
/// The angle between the two faces' normals across the edge at `p`.
|
||||
fn normal_angle(a: &mt::Surface, b: &mt::Surface, p: Vec3) -> f64 {
|
||||
let n = |s: &mt::Surface| {
|
||||
let (u, v) = s.search_nearest_parameter(p3(p), None, 100).unwrap();
|
||||
let n = s.normal(u, v);
|
||||
Vec3::new(n.x, n.y, n.z)
|
||||
};
|
||||
n(a).dot(n(b)).abs().clamp(0.0, 1.0).acos().to_degrees()
|
||||
}
|
||||
|
||||
/// G1 against a flat sheet's open edge: a loft from the edge to a line above, and a patch
|
||||
/// whose sides are arcs leaving the sheet tangent to it. The normals across the shared edge
|
||||
/// agree within 0.1°; the patch joins the sheet into one surface body.
|
||||
#[test]
|
||||
fn g1_meets_the_sheet_tangent() {
|
||||
let base = sheet();
|
||||
let edge =
|
||||
|g| BoundaryCurve { label: "edge".into(), source: CurveSource::Edges { body: &base, at: vec![Vec3::new(5.0, 0.0, 0.0)] }, continuity: g };
|
||||
let far = || line3("far", Vec3::new(0.0, 10.0, 5.0), Vec3::new(10.0, 10.0, 5.0));
|
||||
let loft = boundary_surface(&[edge(Continuity::G1), far()], &[]).unwrap();
|
||||
let plain = boundary_surface(&[edge(Continuity::G0), far()], &[]).unwrap();
|
||||
let (sb, sl, sp) = (face_surface(&base), face_surface(&loft), face_surface(&plain));
|
||||
for k in 0..=10 {
|
||||
let p = Vec3::new(k as f64, 0.0, 0.0);
|
||||
let a = normal_angle(&sb, &sl, p);
|
||||
assert!(a < 0.1, "G1 at x = {k}: {a}°");
|
||||
}
|
||||
assert!(normal_angle(&sb, &sp, Vec3::new(5.0, 0.0, 0.0)) > 10.0, "G0 has a crease");
|
||||
// Arcs in x = 0 and x = 10 from the edge, tangent to the sheet, up to the far line.
|
||||
let arc_plane = |x: f64| Plane::new(Vec3::new(x, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
|
||||
let arc = Seg2::Arc { center: Vec2::new(0.0, 12.5), radius: 12.5, start: -std::f64::consts::FRAC_PI_2, sweep: 0.927_295_218_001_612_2 };
|
||||
let patch = boundary_surface(
|
||||
&[edge(Continuity::G1), far()],
|
||||
&[sketch("left", arc_plane(0.0), vec![arc]), sketch("right", arc_plane(10.0), vec![arc])],
|
||||
)
|
||||
.unwrap();
|
||||
let spa = face_surface(&patch);
|
||||
for k in 0..=10 {
|
||||
let a = normal_angle(&sb, &spa, Vec3::new(k as f64, 0.0, 0.0));
|
||||
assert!(a < 0.1, "G1 patch at x = {k}: {a}°");
|
||||
}
|
||||
let joined = boundary_join(&base, &patch).unwrap();
|
||||
assert_eq!(joined.face_count(), 2);
|
||||
assert!(
|
||||
boundary_join(&base, &boundary_surface(&[far(), line3("other", Vec3::new(0.0, 20.0, 5.0), Vec3::new(10.0, 20.0, 5.0))], &[]).unwrap())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Normal curvature of `s` at the point nearest `p` in the direction `w` (projected into the
|
||||
/// tangent plane), signed against the unit normal `n`.
|
||||
fn normal_curvature(s: &mt::Surface, p: Vec3, w: Vec3, n: Vec3) -> f64 {
|
||||
let (u, v) = s.search_nearest_parameter(p3(p), None, 100).unwrap();
|
||||
let v3 = |x: mt::Vector3| Vec3::new(x.x, x.y, x.z);
|
||||
let (su, sv) = (v3(s.uder(u, v)), v3(s.vder(u, v)));
|
||||
let (suu, suv, svv) = (v3(s.uuder(u, v)), v3(s.uvder(u, v)), v3(s.vvder(u, v)));
|
||||
let (e, f, g) = (su.dot(su), su.dot(sv), sv.dot(sv));
|
||||
let (l, m, nn) = (suu.dot(n), suv.dot(n), svv.dot(n));
|
||||
let (p1, q1) = (w.dot(su), w.dot(sv));
|
||||
let det = e * g - f * f;
|
||||
let (a, b) = ((p1 * g - q1 * f) / det, (q1 * e - p1 * f) / det);
|
||||
(l * a * a + 2.0 * m * a * b + nn * b * b) / (e * a * a + 2.0 * f * a * b + g * b * b)
|
||||
}
|
||||
|
||||
/// G2 against a real cylinder's open edge (R = 10, the face below the edge): across the edge
|
||||
/// the surface's normal curvature is the cylinder's, −1/R about its outward normal, within
|
||||
/// 0.1 % — also when the far curve is sheared along the edge, so that the surface's own
|
||||
/// cross direction leans. With G1 alone it is not.
|
||||
#[test]
|
||||
fn g2_matches_a_cylinder_across_its_edge() {
|
||||
let r = 10.0;
|
||||
let pl = Plane::new(Vec3::ZERO, Vec3::Y, Vec3::Z).unwrap();
|
||||
let arc = Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: r, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::FRAC_PI_2 };
|
||||
let cyl = crate::extrude_sheet(&pl, &[vec![arc]], 0.0, 10.0).unwrap();
|
||||
let sc = face_surface(&cyl);
|
||||
let made = |g, shear: f64| {
|
||||
let edge =
|
||||
BoundaryCurve { label: "rim".into(), source: CurveSource::Edges { body: &cyl, at: vec![Vec3::new(5.0, r, 0.0)] }, continuity: g };
|
||||
let top = line3("top", Vec3::new(shear, 3.0, 12.0), Vec3::new(10.0 + shear, 3.0, 12.0));
|
||||
face_surface(&boundary_surface(&[edge, top], &[]).unwrap())
|
||||
};
|
||||
for shear in [0.0, 5.0] {
|
||||
let sm = made(Continuity::G2, shear);
|
||||
for k in 1..=9 {
|
||||
let p = Vec3::new(k as f64, r, 0.0);
|
||||
let kc = normal_curvature(&sc, p, Vec3::Z, Vec3::Y);
|
||||
assert!((kc + 1.0 / r).abs() < 1e-9, "the cylinder's own curvature: {kc}");
|
||||
let km = normal_curvature(&sm, p, Vec3::Z, Vec3::Y);
|
||||
assert!((km - kc).abs() < 1e-3 / r, "shear {shear}, x = {k}: {km} across the edge where the cylinder has {kc}");
|
||||
// Along the edge both are straight.
|
||||
assert!(normal_curvature(&sm, p, Vec3::X, Vec3::Y).abs() < 1e-6);
|
||||
assert!(normal_angle(&sc, &sm, p) < 0.1);
|
||||
}
|
||||
}
|
||||
let g1 = made(Continuity::G1, 0.0);
|
||||
let k1 = normal_curvature(&g1, Vec3::new(5.0, r, 0.0), Vec3::Z, Vec3::Y);
|
||||
assert!((k1 + 1.0 / r).abs() > 0.1 / r, "G1 alone does not carry the curvature: {k1}");
|
||||
}
|
||||
|
||||
/// G3 is more than a network holds: refused, never a panic.
|
||||
#[test]
|
||||
fn g3_is_not_supported() {
|
||||
let base = sheet();
|
||||
let edge = BoundaryCurve {
|
||||
label: "edge".into(),
|
||||
source: CurveSource::Edges { body: &base, at: vec![Vec3::new(5.0, 0.0, 0.0)] },
|
||||
continuity: Continuity::G3,
|
||||
};
|
||||
let e = boundary_surface(&[edge, line3("far", Vec3::new(0.0, 10.0, 5.0), Vec3::new(10.0, 10.0, 5.0))], &[]).unwrap_err();
|
||||
assert!(e.to_string().contains("not supported yet"), "{e}");
|
||||
}
|
||||
|
||||
/// Refusals: G1 on a sketch curve, on an edge between two faces, curves that miss.
|
||||
#[test]
|
||||
fn bad_boundaries_are_refused() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
let edge = BoundaryCurve {
|
||||
label: "edge".into(),
|
||||
source: CurveSource::Edges { body: &cube, at: vec![Vec3::new(5.0, 0.0, 0.0)] },
|
||||
continuity: Continuity::G1,
|
||||
};
|
||||
let far = line3("far", Vec3::new(0.0, 10.0, 15.0), Vec3::new(10.0, 10.0, 15.0));
|
||||
let e = boundary_surface(&[edge, far], &[]).unwrap_err();
|
||||
assert!(e.to_string().contains("open edge"), "{e}");
|
||||
let mut tangent = line3("t", Vec3::ZERO, Vec3::X);
|
||||
tangent.continuity = Continuity::G1;
|
||||
assert!(boundary_surface(&[tangent, line3("b", Vec3::Y, Vec3::new(1.0, 1.0, 0.0))], &[]).is_err());
|
||||
let miss = boundary_surface(
|
||||
&[line3("a", Vec3::ZERO, Vec3::X * 10.0), line3("b", Vec3::Y * 10.0, Vec3::new(10.0, 10.0, 0.0))],
|
||||
&[line3("c", Vec3::ZERO, Vec3::Y * 10.0), line3("d", Vec3::new(10.0, 0.0, 2.0), Vec3::new(10.0, 10.0, 2.0))],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(miss.to_string().contains("don't meet"), "{miss}");
|
||||
let pts = BoundaryCurve { label: "p".into(), source: CurveSource::Points(vec![Vec3::ZERO; 3]), continuity: Continuity::G0 };
|
||||
assert!(boundary_surface(&[pts, line3("b", Vec3::Y, Vec3::new(1.0, 1.0, 0.0))], &[]).is_err());
|
||||
let nan = BoundaryCurve {
|
||||
label: "n".into(),
|
||||
source: CurveSource::Points(vec![Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]),
|
||||
continuity: Continuity::G0,
|
||||
};
|
||||
assert!(boundary_surface(&[nan, line3("b", Vec3::Y, Vec3::new(1.0, 1.0, 0.0))], &[]).is_err());
|
||||
}
|
||||
}
|
||||
+237
-78
@@ -417,6 +417,18 @@ fn revolved(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec
|
||||
out.push(sphere_solid(plane.to_world(c), rad, [Vec3::X, Vec3::Y, Vec3::Z], &arcs)?);
|
||||
continue;
|
||||
}
|
||||
// A full turn of a profile that comes back to the axis along a curve (a half slot about
|
||||
// its centreline, a dome at the far end): truck's cone gives the faces
|
||||
// swept from it the wrong region (their pole is at the end of their curve), and cuts with
|
||||
// such a body failed (#1853). It is built as part turns are, a third of a turn at a time.
|
||||
if angle.abs() >= std::f64::consts::TAU - 1e-9
|
||||
&& r.holes.is_empty()
|
||||
&& pole_at_end(&r.outer, axis_origin, d)
|
||||
&& let Some(b) = revolve_on_axis(plane, &given.outer, axis_origin, d, angle)?
|
||||
{
|
||||
out.push(b);
|
||||
continue;
|
||||
}
|
||||
// A full revolution of a profile with one edge on the axis: sweep the rest of the loop
|
||||
// as a cone-like shell (no zero-area faces, which booleans can't handle).
|
||||
if angle.abs() >= std::f64::consts::TAU - 1e-9
|
||||
@@ -436,7 +448,7 @@ fn revolved(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec
|
||||
let angle = if toward < 0.0 { -angle } else { angle };
|
||||
// A profile with an edge on the axis: no zero-area face where that edge turns on itself.
|
||||
if r.holes.is_empty()
|
||||
&& let Some(b) = revolve_partial_on_axis(plane, &given.outer, axis_origin, d, angle)?
|
||||
&& let Some(b) = revolve_on_axis(plane, &given.outer, axis_origin, d, angle)?
|
||||
{
|
||||
out.push(b);
|
||||
continue;
|
||||
@@ -1285,11 +1297,47 @@ fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> {
|
||||
(same && (total - std::f64::consts::PI).abs() < 1e-6).then_some((c, r))
|
||||
}
|
||||
|
||||
/// A partial revolve of a loop with one edge on the axis: the other edges sweep their faces,
|
||||
/// the two caps (the profile at the start and at the end) share the axis edge. `None` when the
|
||||
/// loop isn't like that.
|
||||
fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
|
||||
use mt::{BoundedCurve, Invertible, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed};
|
||||
/// Whether the loop has one edge on the axis and comes back to it along a curve (the segment
|
||||
/// before the axis edge is not a line). A line there (a drill point) sweeps a cone, which
|
||||
/// truck's cone builds right.
|
||||
fn pole_at_end(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> bool {
|
||||
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
|
||||
let n = lp.segs.len();
|
||||
let axis_segs: Vec<usize> =
|
||||
(0..n).filter(|i| lp.segs.get(*i).is_some_and(|s| matches!(s, Seg2::Line { a, b } if on_axis(*a) && on_axis(*b)))).collect();
|
||||
let [k] = axis_segs[..] else { return false };
|
||||
lp.segs.get((k + n - 1) % n).is_some_and(|s| !on_axis(s.start()) && !matches!(s, Seg2::Line { .. }))
|
||||
}
|
||||
|
||||
/// The edge from `a` to `b` along the profile segment `sg` (in `plane`), for a revolve's sweep.
|
||||
/// One that ends on the axis (`ends_on_axis`) is built from there and turned round, so that the
|
||||
/// surface swept from its curve has its pole at the curve's start. truck's revolved surface
|
||||
/// reads a point on its axis at the start of its curve with the angle of the point before it,
|
||||
/// but one at the end with an angle of 2π when it has no hint, past a part turn's span: the face
|
||||
/// came out as another region (#1853).
|
||||
fn swept_edge(plane: &Plane, sg: &Seg2, a: &mt::Vertex, b: &mt::Vertex, ends_on_axis: bool) -> mt::Edge {
|
||||
if ends_on_axis {
|
||||
return swept_edge(plane, &sg.reversed(), b, a, false).inverse();
|
||||
}
|
||||
match *sg {
|
||||
Seg2::Line { .. } => builder::line(a, b),
|
||||
Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(sg.mid()))),
|
||||
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => {
|
||||
crate::freeform::edge(plane, sg, a, b).unwrap_or_else(|| builder::line(a, b))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where [`revolve_on_axis`]'s full turns start, from the profile: an angle no plane a part is
|
||||
/// commonly cut along lies at (as full circles start, see `reseam`).
|
||||
const FULL_TURN_START: f64 = 0.4142;
|
||||
|
||||
/// A revolve of a loop with one edge on the axis: the other edges sweep their faces, a third of a
|
||||
/// turn at most each. A partial turn is closed by two caps (the profile at the start and at the
|
||||
/// end), which share the axis edge; a full turn ends where it started. `None` when the loop
|
||||
/// isn't like that.
|
||||
fn revolve_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
|
||||
use mt::{BoundedCurve, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed};
|
||||
let lp = &numbered.lp;
|
||||
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
|
||||
let n = lp.segs.len();
|
||||
@@ -1301,102 +1349,171 @@ fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin:
|
||||
if chain.is_empty() || chain.iter().skip(1).any(|s| on_axis(s.start())) || chain.iter().any(|s| on_axis(s.mid())) {
|
||||
return Ok(None);
|
||||
}
|
||||
let full = angle.abs() >= std::f64::consts::TAU - 1e-9;
|
||||
// Faces of a third of a turn at most, as truck sweeps a full turn: a boolean could not
|
||||
// intersect a half turn's faces and the half circles bounding them (a half slot turned half a
|
||||
// turn into a plate, #1853), and a sphere's face needs room for its poles (below).
|
||||
let pieces = (angle.abs() / (std::f64::consts::TAU / 3.0) - 1e-9).ceil().max(1.0) as usize;
|
||||
let step = angle / pieces as f64;
|
||||
// Built with the axis through the world origin, then moved into place.
|
||||
let shift = plane.to_world(axis_origin);
|
||||
let local = Plane { origin: plane.origin - shift, ..*plane };
|
||||
let plane = &local;
|
||||
let axis = plane.dir_to_world(d);
|
||||
let rot = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle));
|
||||
let half = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle * 0.5));
|
||||
let turned = |by: f64| mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(by));
|
||||
let turn = |m: &mt::Matrix4, p: Vec3| from_p3(mt::EuclideanSpace::from_vec((m * p3(p).to_homogeneous()).truncate()));
|
||||
guard("revolve", || {
|
||||
let fail = |m: &str| KernelError::Failed(format!("revolve: {m}"));
|
||||
let pts: Vec<Vec3> = chain.iter().map(|s| plane.to_world(s.start())).chain(chain.last().map(|s| plane.to_world(s.end()))).collect();
|
||||
let last = pts.len() - 1;
|
||||
let pv: Vec<mt::Vertex> = pts.iter().map(|p| builder::vertex(p3(*p))).collect();
|
||||
// The ends stay where they are (on the axis).
|
||||
let qv: Vec<mt::Vertex> = pts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
if i == 0 || i == last { pv.get(i).cloned().unwrap_or_else(|| builder::vertex(p3(*p))) } else { builder::vertex(p3(turn(&rot, *p))) }
|
||||
})
|
||||
.collect();
|
||||
let mut e: Vec<mt::Edge> = Vec::new();
|
||||
let mut er: Vec<mt::Edge> = Vec::new();
|
||||
for (i, sg) in chain.iter().enumerate() {
|
||||
let (Some(a), Some(b), Some(qa), Some(qb)) = (pv.get(i), pv.get(i + 1), qv.get(i), qv.get(i + 1)) else { return Err(fail("chain")) };
|
||||
let edge = match *sg {
|
||||
Seg2::Line { .. } => builder::line(a, b),
|
||||
Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(sg.mid()))),
|
||||
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => {
|
||||
crate::freeform::edge(plane, sg, a, b).unwrap_or_else(|| builder::line(a, b))
|
||||
}
|
||||
};
|
||||
let curve = edge.oriented_curve().transformed(rot);
|
||||
er.push(mt::Edge::new(qa, qb, curve));
|
||||
e.push(edge);
|
||||
let (Some(a), Some(b)) = (pv.get(i), pv.get(i + 1)) else { return Err(fail("chain")) };
|
||||
e.push(swept_edge(plane, sg, a, b, on_axis(sg.end())));
|
||||
}
|
||||
// Arcs swept by the vertices off the axis.
|
||||
let mut arcs: Vec<Option<mt::Edge>> = vec![None; pts.len()];
|
||||
for i in 1..last {
|
||||
let (Some(a), Some(b), Some(p)) = (pv.get(i), qv.get(i), pts.get(i)) else { continue };
|
||||
if let Some(slot) = arcs.get_mut(i) {
|
||||
*slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p))));
|
||||
// The profile's vertices and edges where each piece starts and ends: turned by the step,
|
||||
// the ends staying where they are (on the axis). A part turn starts on the profile. A full
|
||||
// turn starts at an odd angle and ends where it started: the profile lies in the plane
|
||||
// it was drawn on, often a face of the body cut, and a seam lying in that face as well
|
||||
// left the cut nothing it could intersect there (#1853).
|
||||
let start = if full { FULL_TURN_START } else { 0.0 };
|
||||
let mut levels: Vec<(Vec<mt::Vertex>, Vec<mt::Edge>)> = Vec::new();
|
||||
for j in 0..=pieces {
|
||||
if j == 0 && !full {
|
||||
levels.push((pv.clone(), e.clone()));
|
||||
continue;
|
||||
}
|
||||
if let (true, Some(first)) = (j == pieces && full, levels.first()) {
|
||||
levels.push(first.clone());
|
||||
continue;
|
||||
}
|
||||
let rot = turned(start + step * j as f64);
|
||||
let qv: Vec<mt::Vertex> = pts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
if i == 0 || i == last {
|
||||
pv.get(i).cloned().unwrap_or_else(|| builder::vertex(p3(*p)))
|
||||
} else {
|
||||
builder::vertex(p3(turn(&rot, *p)))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut er: Vec<mt::Edge> = Vec::new();
|
||||
for (i, edge) in e.iter().enumerate() {
|
||||
let (Some(qa), Some(qb)) = (qv.get(i), qv.get(i + 1)) else { return Err(fail("chain")) };
|
||||
let curve = edge.curve().transformed(rot);
|
||||
er.push(if edge.orientation() { mt::Edge::new(qa, qb, curve) } else { mt::Edge::new(qb, qa, curve).inverse() });
|
||||
}
|
||||
levels.push((qv, er));
|
||||
}
|
||||
// Side faces: each edge's swept surface, oriented to agree with its boundary, from the
|
||||
// profile segment the edge was built from.
|
||||
let mut faces: Vec<(mt::Face, FaceProv)> = Vec::new();
|
||||
for (i, sg) in chain.iter().enumerate() {
|
||||
let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) };
|
||||
let mut ws: Vec<mt::Edge> = vec![ei.clone()];
|
||||
if let Some(Some(c)) = arcs.get(i + 1) {
|
||||
ws.push(c.clone());
|
||||
for (j, piece) in levels.windows(2).enumerate() {
|
||||
let [(pv, e), (qv, er)] = piece else { return Err(fail("pieces")) };
|
||||
let half = turned(start + step * (j as f64 + 0.5));
|
||||
// Arcs swept by the vertices off the axis.
|
||||
let mut arcs: Vec<Option<mt::Edge>> = vec![None; pts.len()];
|
||||
for i in 1..last {
|
||||
let (Some(a), Some(b), Some(p)) = (pv.get(i), qv.get(i), pts.get(i)) else { continue };
|
||||
if let Some(slot) = arcs.get_mut(i) {
|
||||
*slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p))));
|
||||
}
|
||||
}
|
||||
ws.push(eri.inverse());
|
||||
if let Some(Some(c)) = arcs.get(i) {
|
||||
ws.push(c.inverse());
|
||||
}
|
||||
let wire: mt::Wire = ws.into();
|
||||
let radial = matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9));
|
||||
let f = if radial {
|
||||
builder::try_attach_plane(&[wire]).map_err(|er| fail(&er.to_string()))?
|
||||
} else {
|
||||
let template = builder::rsweep(ei, mt::Point3::new(0.0, 0.0, 0.0), v3(axis), mt::Rad(angle));
|
||||
let mut surface = template.face_iter().next().map(|f| f.oriented_surface()).ok_or_else(|| fail("swept face"))?;
|
||||
// The loop's turning (Newell) against the surface normal halfway round.
|
||||
let mut nw = Vec3::ZERO;
|
||||
let poly: Vec<Vec3> = wire
|
||||
.edge_iter()
|
||||
.flat_map(|ed| {
|
||||
let c = ed.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
(0..8).map(move |j| from_p3(c.subs(t0 + (t1 - t0) * j as f64 / 8.0))).collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
for (j, p) in poly.iter().enumerate() {
|
||||
if let Some(q) = poly.get((j + 1) % poly.len()) {
|
||||
nw += p.cross(*q);
|
||||
for (i, sg) in chain.iter().enumerate() {
|
||||
let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) };
|
||||
let mut ws: Vec<mt::Edge> = vec![ei.clone()];
|
||||
if let Some(Some(c)) = arcs.get(i + 1) {
|
||||
ws.push(c.clone());
|
||||
}
|
||||
ws.push(eri.inverse());
|
||||
if let Some(Some(c)) = arcs.get(i) {
|
||||
ws.push(c.inverse());
|
||||
}
|
||||
let wire: mt::Wire = ws.into();
|
||||
let radial = matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9));
|
||||
let f = if radial {
|
||||
builder::try_attach_plane(&[wire]).map_err(|er| fail(&er.to_string()))?
|
||||
} else {
|
||||
// The surface turns from the piece's start through |step|, so its angle runs
|
||||
// from 0 across the face. Inverting it turns its axis round, which puts the
|
||||
// face at 2π - |step| to 2π: its start then lies on the angle's seam, where
|
||||
// points (and a pole without a hint) read as 0, the far side, and the face
|
||||
// meshed and intersected as the wrong region (#1853). The face is turned
|
||||
// round instead.
|
||||
let (dir, by) = if step < 0.0 { (-axis, -step) } else { (axis, step) };
|
||||
let surface = match *sg {
|
||||
// An arc about a centre on the axis that reaches it sweeps part of a sphere
|
||||
// with its pole there, and a boolean can't follow an intersection into a
|
||||
// pole: a cut by the plane through the axis (the face the profile was drawn
|
||||
// on) failed (#1853). The face lies on the sphere turned about the axis
|
||||
// square to the revolve's and to the face's middle, as `sphere_solid`'s
|
||||
// patches do: its poles are a quarter turn from that middle, past the
|
||||
// face's third of a turn, and its seam runs round the back.
|
||||
Seg2::Arc { center, radius, .. } if on_axis(center) && (on_axis(sg.start()) || on_axis(sg.end())) => {
|
||||
let c = plane.to_world(center);
|
||||
let out = turn(&half, plane.to_world(sg.mid())) - c;
|
||||
let out = (out - axis * out.dot(axis)).normalized().ok_or_else(|| fail("sphere"))?;
|
||||
let pole = axis.cross(out);
|
||||
let meridian = builder::circle_arc(
|
||||
&builder::vertex(p3(c + pole * radius)),
|
||||
&builder::vertex(p3(c - pole * radius)),
|
||||
p3(c - out * radius),
|
||||
);
|
||||
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(
|
||||
meridian.oriented_curve(),
|
||||
p3(c),
|
||||
v3(pole),
|
||||
)))
|
||||
}
|
||||
_ => {
|
||||
let template = builder::rsweep(ei, mt::Point3::new(0.0, 0.0, 0.0), v3(dir), mt::Rad(by));
|
||||
template.face_iter().next().map(|f| f.surface()).ok_or_else(|| fail("swept face"))?
|
||||
}
|
||||
};
|
||||
// The loop's turning (Newell) against the surface normal halfway round.
|
||||
let mut nw = Vec3::ZERO;
|
||||
let poly: Vec<Vec3> = wire
|
||||
.edge_iter()
|
||||
.flat_map(|ed| {
|
||||
let c = ed.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
(0..8).map(move |j| from_p3(c.subs(t0 + (t1 - t0) * j as f64 / 8.0))).collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
for (j, p) in poly.iter().enumerate() {
|
||||
if let Some(q) = poly.get((j + 1) % poly.len()) {
|
||||
nw += p.cross(*q);
|
||||
}
|
||||
}
|
||||
}
|
||||
let probe = turn(&half, plane.to_world(sg.mid()));
|
||||
let sn = surface.search_nearest_parameter(p3(probe), None, 100).map(|(u, v)| surface.normal(u, v));
|
||||
if sn.is_some_and(|m| nw.dot(Vec3::new(m.x, m.y, m.z)) < 0.0) {
|
||||
surface = surface.inverse();
|
||||
}
|
||||
mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))?
|
||||
};
|
||||
faces.push((f, record::profile(numbered.numbers((k + 1 + i) % n))));
|
||||
let probe = turn(&half, plane.to_world(sg.mid()));
|
||||
let sn = surface.search_nearest_parameter(p3(probe), None, 100).map(|(u, v)| surface.normal(u, v));
|
||||
if sn.is_some_and(|m| nw.dot(Vec3::new(m.x, m.y, m.z)) < 0.0) {
|
||||
mt::Face::try_new(vec![wire.inverse()], surface).map_err(|er| fail(&er.to_string()))?.inverse()
|
||||
} else {
|
||||
mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))?
|
||||
}
|
||||
};
|
||||
faces.push((f, record::profile(numbered.numbers((k + 1 + i) % n))));
|
||||
}
|
||||
}
|
||||
let (faces, mut history): (Vec<mt::Face>, Vec<FaceProv>) = faces.into_iter().unzip();
|
||||
let close = |solid: Solid, history: Vec<FaceProv>| {
|
||||
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
|
||||
record::attach(Body::new(solid)?, &[], Some(history), Default::default()).map(Some)
|
||||
};
|
||||
if full {
|
||||
return close(Solid::try_new(vec![faces.into()]).map_err(|er| fail(&er.to_string()))?, history);
|
||||
}
|
||||
// Caps: the profile at the start and at the end, sharing the axis edge.
|
||||
let (Some(a0), Some(an)) = (pv.first(), pv.get(last)) else { return Err(fail("ends")) };
|
||||
let ((Some(a0), Some(an)), Some((_, er))) = ((pv.first(), pv.get(last)), levels.last()) else { return Err(fail("ends")) };
|
||||
let axis_edge = builder::line(an, a0);
|
||||
let start: mt::Wire = e.iter().rev().map(|x| x.inverse()).chain(std::iter::once(axis_edge.inverse())).collect::<Vec<_>>().into();
|
||||
let end: mt::Wire = er.iter().cloned().chain(std::iter::once(axis_edge.clone())).collect::<Vec<_>>().into();
|
||||
let cs = builder::try_attach_plane(&[start]).map_err(|er| fail(&er.to_string()))?;
|
||||
let ce = builder::try_attach_plane(&[end]).map_err(|er| fail(&er.to_string()))?;
|
||||
let (faces, mut history): (Vec<mt::Face>, Vec<FaceProv>) = faces.into_iter().unzip();
|
||||
history.extend([record::cap(false), record::cap(true)]);
|
||||
let mut last_err = String::new();
|
||||
for flip in 0..4 {
|
||||
@@ -1404,10 +1521,7 @@ fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin:
|
||||
all.push(if flip & 1 == 0 { cs.clone() } else { cs.inverse() });
|
||||
all.push(if flip & 2 == 0 { ce.clone() } else { ce.inverse() });
|
||||
match Solid::try_new(vec![all.into()]) {
|
||||
Ok(solid) => {
|
||||
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
|
||||
return record::attach(Body::new(solid)?, &[], Some(history), Default::default()).map(Some);
|
||||
}
|
||||
Ok(solid) => return close(solid, history),
|
||||
Err(er) => last_err = er.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -1562,6 +1676,15 @@ pub(crate) fn cap_and_close(sides: Vec<Side>, w0: &[mt::Wire], w1: &[mt::Wire],
|
||||
Err(KernelError::Failed(format!("closing the shell: {last}")))
|
||||
}
|
||||
|
||||
/// A sweep whose walls already close, including separate shells around profile holes.
|
||||
pub(crate) fn close_sweep(sides: Vec<Side>) -> Result<Body> {
|
||||
let sources: std::collections::HashMap<_, _> = sides.iter().map(|(f, p)| (f.id(), p.clone())).collect();
|
||||
let shell: mt::Shell = sides.into_iter().map(|(f, _)| f).collect();
|
||||
let solid = Solid::try_new(shell.connected_components()).map_err(|e| KernelError::Failed(format!("closing the sweep: {e}")))?;
|
||||
let history = solid.face_iter().map(|f| sources.get(&f.id()).cloned().flatten()).collect();
|
||||
record::attach(Body::new(solid)?, &[], history, Default::default())
|
||||
}
|
||||
|
||||
/// Ruled loft through two or more planar sections (outer loops only). The sections and the
|
||||
/// same sections in reverse order give the same body.
|
||||
pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
|
||||
@@ -1881,6 +2004,25 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
|
||||
if corner {
|
||||
return crate::sweep_path::sweep_path(plane, region, &path_polyline(path)?);
|
||||
}
|
||||
let points = path_polyline(path)?;
|
||||
if crate::sweep_path::closed_path(&points) {
|
||||
// Sketch circles have two half arcs. Sweep the whole turn in one operation so truck
|
||||
// shares the closing edges itself. Other closed paths use stations with shared ends.
|
||||
if let Some((center, axis, angle)) = full_circle(path) {
|
||||
return guard("closed sweep", || {
|
||||
let profile = region_wires(plane, &oriented(region)?)?;
|
||||
let mut sides = Vec::new();
|
||||
for p in &profile {
|
||||
let shell: mt::Shell = builder::rsweep(&p.wire, p3(center), v3(axis), mt::Rad(angle));
|
||||
let numbers = seeds(std::slice::from_ref(p));
|
||||
let prov = record::columns(shell.face_iter(), &numbers, None, record::Column::ToEnd, &[]);
|
||||
sides.extend(shell.face_iter().enumerate().map(|(i, f)| (f.clone(), prov.as_ref().and_then(|p| p.get(i)).cloned().flatten())));
|
||||
}
|
||||
close_sweep(sides)
|
||||
});
|
||||
}
|
||||
return crate::sweep_path::sweep_path(plane, region, &points);
|
||||
}
|
||||
// The outer loop and each hole, swept along the path; the ends are capped with holes.
|
||||
let profile = region_wires(plane, &oriented(region)?)?;
|
||||
let starts = wires_of(&profile);
|
||||
@@ -1927,6 +2069,23 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
|
||||
})
|
||||
}
|
||||
|
||||
/// A connected, single turn about one centre and axis, possibly split into sketch arcs.
|
||||
fn full_circle(path: &[PathSeg]) -> Option<(Vec3, Vec3, f64)> {
|
||||
let PathSeg::Arc { a: first, center, axis, .. } = *path.first()? else { return None };
|
||||
let axis = axis.normalized()?;
|
||||
let mut end = first;
|
||||
let mut total = 0.0;
|
||||
for seg in path {
|
||||
let PathSeg::Arc { a, center: c, axis: k, angle } = *seg else { return None };
|
||||
if a.dist(end) > 1e-7 || c.dist(center) > 1e-7 || k.normalized()?.dist(axis) > 1e-9 || !angle.is_finite() {
|
||||
return None;
|
||||
}
|
||||
total += angle;
|
||||
end = rotate_about(a, c, axis, angle);
|
||||
}
|
||||
((total.abs() - std::f64::consts::TAU).abs() < 1e-9).then_some((center, axis, total))
|
||||
}
|
||||
|
||||
/// Cox–de Boor basis values N_{j,3}(u) for all j.
|
||||
fn cubic_basis(knots: &[f64], n_ctrl: usize, u: f64) -> Vec<f64> {
|
||||
let p = 3;
|
||||
|
||||
@@ -1193,10 +1193,18 @@ pub(crate) fn imprint_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool)
|
||||
/// faces): every such pair of faces goes and the shells are stitched along their edges. `None`
|
||||
/// when no such pair of faces exists.
|
||||
pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
glue_impl(a, b, false)
|
||||
}
|
||||
|
||||
/// Glue every certified start cap, retaining each tool's original contact tolerance.
|
||||
pub(crate) fn glue_all_caps(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
glue_impl(a, b, true)
|
||||
}
|
||||
|
||||
fn glue_impl(a: &Body, b: &Body, all_caps: bool) -> Option<Result<Body>> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (size * 1e-7).max(1e-9) * 10.0;
|
||||
let key = |p: Vec3| ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64);
|
||||
let mid = |e: &mt::Edge| {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
@@ -1205,14 +1213,40 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
// Matching faces: same plane, opposite normals, the same edges. Every pair is glued: a
|
||||
// pair left behind would stay inside the result as a zero-thickness slit (issue #616).
|
||||
let (pa, pb) = (planes(a), planes(b));
|
||||
let mut tool_sizes = HashMap::new();
|
||||
if all_caps {
|
||||
for shell in b.solid.boundaries() {
|
||||
let mut bounds = solidcraft_geom::Aabb3::EMPTY;
|
||||
for vertex in shell.vertex_iter() {
|
||||
bounds.add(from_p3(vertex.point()));
|
||||
}
|
||||
let size = bounds.diagonal();
|
||||
if !size.is_finite() {
|
||||
return None;
|
||||
}
|
||||
for face in shell.face_iter() {
|
||||
tool_sizes.insert(face.id(), size);
|
||||
}
|
||||
}
|
||||
}
|
||||
let fa_list: Vec<&mt::Face> = a.solid.face_iter().collect();
|
||||
let fb_list: Vec<&mt::Face> = b.solid.face_iter().collect();
|
||||
// B edge → the matching A edge; B vertex → the matching A vertex.
|
||||
let mut map: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
let mut vmap: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
|
||||
let (mut glued_a, mut glued_b): (Vec<usize>, Vec<usize>) = (Vec::new(), Vec::new());
|
||||
if all_caps && pa.len().saturating_mul(pb.len()) > 2_000_000 {
|
||||
return None;
|
||||
}
|
||||
for (ia, x) in &pa {
|
||||
for (ib, y) in &pb {
|
||||
if all_caps
|
||||
&& !matches!(b.face_sources().and_then(|sources| sources.get(*ib)), Some(Some(crate::FaceSource::ExtrudeCap { high: false, .. })))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let tol = if all_caps { (a.size().max(*tool_sizes.get(&fb_list.get(*ib)?.id())?) * 1e-7).max(1e-9) * 10.0 } else { tol };
|
||||
let key = |p: Vec3| ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64);
|
||||
if glued_a.contains(ia) || glued_b.contains(ib) || x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol {
|
||||
continue;
|
||||
}
|
||||
@@ -1264,6 +1298,14 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
glued_b.push(*ib);
|
||||
}
|
||||
}
|
||||
if all_caps
|
||||
&& b.face_sources()?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, source)| matches!(source, Some(crate::FaceSource::ExtrudeCap { high: false, .. })) && !glued_b.contains(&index))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if glued_a.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Issue #2005: a closed sweep must join its end back to its start, without coincident caps.
|
||||
//! The capped tool used to stall or fail when cutting the torus it lay inside.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
|
||||
|
||||
use crate::{Body, BoolOp, PathSeg, SweepControl, boolean, measure, sweep_with};
|
||||
|
||||
fn square(half: f64) -> Region2 {
|
||||
Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::new(-half, -half), Vec2::new(half, -half), Vec2::new(half, half), Vec2::new(-half, half)]),
|
||||
holes: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_plane() -> Plane {
|
||||
Plane { origin: Vec3::new(30.0, 0.0, 0.0), x: Vec3::X, y: Vec3::Z }
|
||||
}
|
||||
|
||||
fn circle_path() -> [PathSeg; 2] {
|
||||
// A sketch circle reaches the kernel as two half arcs.
|
||||
[
|
||||
PathSeg::Arc { a: Vec3::new(30.0, 0.0, 0.0), center: Vec3::ZERO, axis: Vec3::Z, angle: PI },
|
||||
PathSeg::Arc { a: Vec3::new(-30.0, 0.0, 0.0), center: Vec3::ZERO, axis: Vec3::Z, angle: PI },
|
||||
]
|
||||
}
|
||||
|
||||
fn tube(body: &Body, volume: f64, genus: usize) {
|
||||
let m = measure(body).unwrap();
|
||||
assert!((m.volume - volume).abs() < volume * 0.01, "{m:?}, expected {volume}");
|
||||
// A closed orientable tube has Euler characteristic 2 - 2g. Coincident caps gave 2.
|
||||
assert_eq!(m.vertices + m.faces + 2 * genus, m.edges + 2, "{m:?}");
|
||||
assert!(body.solid.boundaries().iter().all(|s| s.extract_boundaries().is_empty()));
|
||||
let h = body.history().expect("sweep provenance");
|
||||
assert!(h.faces.iter().all(|f| f.origins.iter().all(|o| matches!(o, crate::history::FaceOrigin::Profile { .. }))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_circle_sweeps_have_no_caps() {
|
||||
for twist in [0.0, FRAC_PI_2, PI, TAU, -PI] {
|
||||
let ctl = SweepControl { twist, ..SweepControl::default() };
|
||||
let b = sweep_with(&profile_plane(), &square(4.0), &circle_path(), &ctl).unwrap();
|
||||
tube(&b, 64.0 * TAU * 30.0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_full_arc_closes_like_a_sketch_circle() {
|
||||
for angle in [TAU, -TAU] {
|
||||
let path = [PathSeg::Arc { a: Vec3::new(30.0, 0.0, 0.0), center: Vec3::ZERO, axis: Vec3::Z, angle }];
|
||||
let b = sweep_with(&profile_plane(), &square(4.0), &path, &SweepControl::default()).unwrap();
|
||||
tube(&b, 64.0 * TAU * 30.0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_polyline_sweep_shares_its_starting_section() {
|
||||
let path = crate::path_polyline(&circle_path()).unwrap();
|
||||
let b = crate::sweep_path(&profile_plane(), &square(4.0), &path).unwrap();
|
||||
tube(&b, 64.0 * TAU * 30.0, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_sweep_refuses_an_end_section_that_does_not_match() {
|
||||
for ctl in [SweepControl { twist: PI / 4.0, ..SweepControl::default() }, SweepControl { scale: 2.0, ..SweepControl::default() }] {
|
||||
let err = sweep_with(&profile_plane(), &square(4.0), &circle_path(), &ctl).unwrap_err();
|
||||
assert!(err.to_string().contains("closed path"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_holed_sweep_joins_every_loop() {
|
||||
let region = Region2 { holes: vec![square(2.0).outer], ..square(4.0) };
|
||||
let ctl = SweepControl { twist: PI, ..SweepControl::default() };
|
||||
let b = sweep_with(&profile_plane(), ®ion, &circle_path(), &ctl).unwrap();
|
||||
// Two disjoint toroidal shells, one bounding the cavity.
|
||||
let m = measure(&b).unwrap();
|
||||
assert!((m.volume - 48.0 * TAU * 30.0).abs() < m.volume * 0.02, "{m:?}");
|
||||
assert_eq!(m.vertices + m.faces, m.edges, "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_turn_sweep_cuts_a_torus() {
|
||||
let base = crate::torus(Vec3::ZERO, 30.0, 8.0).unwrap();
|
||||
let tool = sweep_with(&profile_plane(), &square(4.0), &circle_path(), &SweepControl { twist: PI, ..SweepControl::default() }).unwrap();
|
||||
let result = boolean(&base, &tool, BoolOp::Cut).unwrap().expect("cut leaves the torus wall");
|
||||
let expected = (PI * 64.0 - 64.0) * TAU * 30.0;
|
||||
let m = measure(&result).unwrap();
|
||||
assert!((m.volume - expected).abs() < expected * 0.015, "{m:?}, expected {expected}");
|
||||
}
|
||||
@@ -25,6 +25,7 @@ mod boolean_guard;
|
||||
mod boolean_record;
|
||||
mod bore_guard;
|
||||
mod boundary;
|
||||
mod boundary_network;
|
||||
mod bridge;
|
||||
mod build;
|
||||
mod clearance;
|
||||
@@ -59,6 +60,8 @@ mod issue_1080_tests;
|
||||
#[cfg(test)]
|
||||
mod issue_1510_tests;
|
||||
#[cfg(test)]
|
||||
mod issue_2005_tests;
|
||||
#[cfg(test)]
|
||||
mod issue_324_tests;
|
||||
#[cfg(test)]
|
||||
mod issue_616_tests;
|
||||
@@ -119,6 +122,8 @@ mod surface_extend;
|
||||
mod surface_kind;
|
||||
mod surface_offset;
|
||||
mod surface_ruled;
|
||||
#[cfg(test)]
|
||||
mod surface_second_derivative_tests;
|
||||
mod surface_untrim;
|
||||
mod surfaces;
|
||||
mod sweep_path;
|
||||
@@ -143,6 +148,7 @@ pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fill
|
||||
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
|
||||
pub use boundary::{Boundary, boundary_patch};
|
||||
pub use boundary_network::{BoundaryCurve, CurveSource, MAX_CHAIN_CURVES, MAX_EDGE_PICKS, boundary_join, boundary_surface};
|
||||
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
|
||||
pub use build::{
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve,
|
||||
@@ -173,7 +179,10 @@ pub use mesh_convert::convert_prismatic_mesh;
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
pub use native::{decode_native_body, encode_native_body, native_preview_mesh};
|
||||
pub use offset::with_same_surface;
|
||||
pub use ops::{BoolOp, boolean, can_join_touching_faces, half_space, join_apart, split_by_plane, transform, transform_matrix, union_apart};
|
||||
pub use ops::{
|
||||
BoolOp, boolean, can_join_all_touching_faces, can_join_touching_faces, compound_disjoint_extrusions, half_space, is_planar, join_apart,
|
||||
split_by_plane, transform, transform_matrix, union_apart,
|
||||
};
|
||||
pub use polybool::{faceted_boolean, planar_boolean};
|
||||
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
|
||||
pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean};
|
||||
|
||||
+271
-9
@@ -23,12 +23,167 @@ pub enum BoolOp {
|
||||
Intersect,
|
||||
}
|
||||
|
||||
/// Exact planar Booleans need no sampled enlargement of their tool footprint.
|
||||
pub fn is_planar(body: &Body) -> bool {
|
||||
if body.is_mesh() || body.face_count() > 16_384 {
|
||||
return false;
|
||||
}
|
||||
guard("planar body", || Ok(body.solid.face_iter().all(|face| matches!(face.oriented_surface(), mt::Surface::Plane(_))))).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Batch separated extrusion tools without repeatedly unioning an ever-growing target.
|
||||
/// Construction caps provide conservative bounds, including the full circle of each arc;
|
||||
/// unsupported or touching tools retain the caller's sequential Boolean path.
|
||||
pub fn compound_disjoint_extrusions(tools: &[Body]) -> Result<Option<Body>> {
|
||||
if tools.len() < 2 || tools.len() > 1024 {
|
||||
return Ok(None);
|
||||
}
|
||||
guard("compound extrusion tools", || {
|
||||
let mut bounds = Vec::with_capacity(tools.len());
|
||||
let mut corners = Vec::with_capacity(tools.len());
|
||||
let mut axes = Vec::with_capacity(tools.len());
|
||||
let mut remaining = 16_384usize;
|
||||
for tool in tools {
|
||||
let Some(sources) = tool.face_sources() else { return Ok(None) };
|
||||
if sources.len() != tool.face_count() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut bb = solidcraft_geom::Aabb3::EMPTY;
|
||||
let mut points = Some(Vec::new());
|
||||
let mut directions = Vec::new();
|
||||
let mut caps = 0;
|
||||
for source in sources {
|
||||
let Some(left) = remaining.checked_sub(1) else { return Ok(None) };
|
||||
remaining = left;
|
||||
match source {
|
||||
Some(crate::FaceSource::ExtrudeCap { region, plane, high }) => {
|
||||
caps += 1;
|
||||
// The bottom support is outward-facing, but its region retains
|
||||
// the original sketch coordinates (see build::extrude).
|
||||
let plane = if *high { *plane } else { solidcraft_geom::Plane { y: -plane.y, ..*plane } };
|
||||
for segment in ®ion.outer.segs {
|
||||
let Some(left) = remaining.checked_sub(1) else { return Ok(None) };
|
||||
remaining = left;
|
||||
match *segment {
|
||||
solidcraft_geom::Seg2::Line { a, b } => {
|
||||
let (a, b) = (plane.to_world(a), plane.to_world(b));
|
||||
bb.add(a);
|
||||
bb.add(b);
|
||||
if let Some(points) = points.as_mut() {
|
||||
points.extend([a, b]);
|
||||
}
|
||||
if let Some(axis) = (b - a).cross(plane.normal()).normalized() {
|
||||
directions.push(axis);
|
||||
}
|
||||
}
|
||||
solidcraft_geom::Seg2::Arc { center, radius, .. } => {
|
||||
points = None;
|
||||
for x in [-radius, radius] {
|
||||
for y in [-radius, radius] {
|
||||
bb.add(plane.to_world(center + solidcraft_geom::Vec2::new(x, y)));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crate::FaceSource::ExtrudeSide { .. }) => {}
|
||||
_ => return Ok(None),
|
||||
}
|
||||
}
|
||||
if caps != 2 || bb.is_empty() || !bb.min.is_finite() || !bb.max.is_finite() {
|
||||
return Ok(None);
|
||||
}
|
||||
bounds.push(bb);
|
||||
corners.push(points);
|
||||
axes.push(directions);
|
||||
}
|
||||
let mut remaining_separation_work = 16_000_000usize;
|
||||
for (i, a) in bounds.iter().enumerate() {
|
||||
for (j, b) in bounds.iter().enumerate().skip(i + 1) {
|
||||
let gap = (a.diagonal().max(b.diagonal()) * 1e-6).max(1e-7);
|
||||
let separated = a.max.x + gap < b.min.x
|
||||
|| b.max.x + gap < a.min.x
|
||||
|| a.max.y + gap < b.min.y
|
||||
|| b.max.y + gap < a.min.y
|
||||
|| a.max.z + gap < b.min.z
|
||||
|| b.max.z + gap < a.min.z;
|
||||
if separated {
|
||||
continue;
|
||||
}
|
||||
let (Some(Some(x)), Some(Some(y))) = (corners.get(i), corners.get(j)) else { return Ok(None) };
|
||||
let Some(origin) = x.first() else { return Ok(None) };
|
||||
// Every straight extrusion lies inside the hull of its cap corners.
|
||||
// A separating cap-edge axis therefore proves clearance even when
|
||||
// rotated tools' world-coordinate boxes overlap. Arcs retain the box test.
|
||||
let range = |points: &[Vec3], axis: Vec3| {
|
||||
points.iter().map(|p| (*p - *origin).dot(axis)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v)))
|
||||
};
|
||||
let rounding = origin.x.abs().max(origin.y.abs()).max(origin.z.abs()) * f64::EPSILON * 128.0;
|
||||
let separated = axes.get(i).into_iter().flatten().chain(axes.get(j).into_iter().flatten()).any(|axis| {
|
||||
let Some(left) = remaining_separation_work.checked_sub(x.len().saturating_add(y.len())) else { return false };
|
||||
remaining_separation_work = left;
|
||||
let (a, b) = (range(x, *axis), range(y, *axis));
|
||||
a.1 + gap + rounding < b.0 || b.1 + gap + rounding < a.0
|
||||
});
|
||||
if !separated {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
let sources: std::collections::HashMap<_, _> = tools
|
||||
.iter()
|
||||
.flat_map(|tool| {
|
||||
tool.solid
|
||||
.face_iter()
|
||||
.zip(tool.face_sources().unwrap_or_default())
|
||||
.map(|(face, source)| (face.id(), (face.orientation(), source.clone())))
|
||||
})
|
||||
.collect();
|
||||
let operands: Vec<_> = tools.iter().collect();
|
||||
let shells = tools.iter().flat_map(|tool| tool.solid.boundaries().clone()).collect();
|
||||
let solid = Solid::try_new(shells).map_err(|error| KernelError::Failed(format!("extrusion compound: {error}")))?;
|
||||
let faces = tools
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(operand, tool)| (0..tool.face_count()).map(move |face| record::from_face(operand, face, crate::history::Change::Kept)))
|
||||
.collect();
|
||||
let mut edges = HashMap::new();
|
||||
for (operand, tool) in tools.iter().enumerate() {
|
||||
edges.extend(record::edge_origins(&tool.solid, operand));
|
||||
}
|
||||
record::build(solid, &record::counts(&operands), Some(faces), Some(&edges)).map(|mut body| {
|
||||
body.regularization_protected = tools.iter().any(|tool| tool.regularization_protected);
|
||||
// Only unchanged face identities carry these transient tool witnesses.
|
||||
// No whole-compound extrusion/producer certificate is created.
|
||||
let mapped = body
|
||||
.solid
|
||||
.face_iter()
|
||||
.map(|face| {
|
||||
sources.get(&face.id()).filter(|(orientation, _)| *orientation == face.orientation()).and_then(|(_, source)| source.clone())
|
||||
})
|
||||
.collect();
|
||||
Some(body.with_face_sources(Some(std::sync::Arc::new(mapped))))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a union can sew two matching planar faces without intersecting them.
|
||||
/// Callers can retain the exact touching tool instead of extending it into material.
|
||||
pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
|
||||
a.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1 && crate::coplanar::glue(a, b).is_some_and(|r| r.is_ok())
|
||||
}
|
||||
|
||||
/// Check all separated extrusion starts in one construction, without rebuilding
|
||||
/// the unchanged target once for each face. Partial seams keep the ordinary path.
|
||||
pub fn can_join_all_touching_faces(a: &Body, b: &Body) -> bool {
|
||||
if a.solid.boundaries().len() != 1 || !(2..=1024).contains(&b.solid.boundaries().len()) || a.face_count().max(b.face_count()) > 16_384 {
|
||||
return false;
|
||||
}
|
||||
guard("check extrusion seams", || Ok(crate::coplanar::glue_all_caps(a, b).is_some_and(|result| result.is_ok()))).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
|
||||
/// ray whose direction is derived from point coordinates; moving both solids (and the result
|
||||
/// back) leaves the geometry unchanged but changes those rays.
|
||||
@@ -225,6 +380,110 @@ fn trivial(
|
||||
mod shortcut_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn seam_roundoff_is_not_a_thin_meeting_but_real_thin_solids_are() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
|
||||
// This circle's seams lie on the X axis: its cylinder's vertex box is flat in Y.
|
||||
let region = Region2 { outer: Loop2::circle(Vec2::ZERO, 8.0), holes: vec![] };
|
||||
let periodic = crate::extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap();
|
||||
let block = crate::box_solid(Vec3::new(-40.0, -40.0, -10.0), Vec3::new(40.0, 40.0, 10.0)).unwrap();
|
||||
assert!(meeting_size(&periodic, &block).is_infinite());
|
||||
let thin = crate::box_solid(Vec3::ZERO, Vec3::new(80.0, 80.0, 1e-5)).unwrap();
|
||||
assert!((meeting_size(&thin, &thin) - 1e-5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_extrusion_bounds_use_exact_cap_separation_and_keep_sources() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let rectangle = |angle: f64, offset: f64| {
|
||||
let (c, s) = (angle.cos(), angle.sin());
|
||||
Region2 {
|
||||
outer: Loop2::polygon(
|
||||
&[(-2., -0.5), (2., -0.5), (2., 0.5), (-2., 0.5)].map(|(x, y)| Vec2::new(x * c - (y + offset) * s, x * s + (y + offset) * c)),
|
||||
),
|
||||
holes: vec![],
|
||||
}
|
||||
};
|
||||
for offset in [1.2, 1., 0.8] {
|
||||
let tools = crate::extrude(&Plane::XY, &[rectangle(0.7, 0.), rectangle(0.7, offset)], 0., 2.).unwrap();
|
||||
let compound = compound_disjoint_extrusions(&tools).unwrap();
|
||||
assert_eq!(compound.is_some(), offset > 1.);
|
||||
if let Some(compound) = compound {
|
||||
let sources = compound.face_sources().unwrap();
|
||||
assert_eq!(sources.len(), compound.face_count());
|
||||
assert_eq!(sources.iter().filter(|source| matches!(source, Some(crate::FaceSource::ExtrudeCap { high: false, .. }))).count(), 2);
|
||||
assert!(compound.validity().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batched_contact_test_matches_individual_tolerances() {
|
||||
let target = crate::cylinder(Vec3::ZERO, Vec3::Z, 5., 3.).unwrap();
|
||||
let tools = [
|
||||
crate::box_solid(Vec3::new(-1., -1., 3.), Vec3::new(1., 1., 5.)).unwrap(),
|
||||
crate::box_solid(Vec3::new(-1., -1., 3.002), Vec3::new(1., 1., 5.)).unwrap(),
|
||||
crate::sphere(Vec3::new(20., 0., 1.), 1.).unwrap(),
|
||||
];
|
||||
let expected = tools.iter().map(|tool| target.meets(tool).unwrap()).collect::<Vec<_>>();
|
||||
assert_eq!(target.meets_each(&tools).unwrap(), expected);
|
||||
assert!(target.meets_each(&[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_span_does_not_relax_full_face_seam_tolerance() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let mut target = crate::box_solid(Vec3::new(0., 0., -3.), Vec3::new(100., 2., -2.)).unwrap();
|
||||
for x in [0., 98.] {
|
||||
let boss = crate::box_solid(Vec3::new(x, 0., -2.), Vec3::new(x + 2., 2., 0.)).unwrap();
|
||||
target = boolean(&target, &boss, BoolOp::Union).unwrap().unwrap();
|
||||
}
|
||||
let regions: Vec<_> = [0., 98.]
|
||||
.into_iter()
|
||||
.map(|x| Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::new(x, 0.), Vec2::new(x + 2., 0.), Vec2::new(x + 2., 2.), Vec2::new(x, 2.)]),
|
||||
holes: vec![],
|
||||
})
|
||||
.collect();
|
||||
let tools = crate::extrude(&Plane::XY, ®ions, 0.000_105, 200.).unwrap();
|
||||
assert!(tools.iter().all(|tool| !can_join_touching_faces(&target, tool)));
|
||||
let compound = compound_disjoint_extrusions(&tools).unwrap().unwrap();
|
||||
assert!(!can_join_all_touching_faces(&target, &compound));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrusion_compound_requires_separated_construction_bounds() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let rectangle = |x: f64| Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::new(x, 0.0), Vec2::new(x + 2.0, 0.0), Vec2::new(x + 2.0, 2.0), Vec2::new(x, 2.0)]),
|
||||
holes: vec![],
|
||||
};
|
||||
let plane = Plane::from_normal(Vec3::new(100.0, -20.0, 8.0), Vec3::new(1.0, 2.0, 3.0)).unwrap();
|
||||
let tools = crate::extrude(&plane, &[rectangle(0.0), rectangle(20.0)], -1.0, 5.0).unwrap();
|
||||
let compound = compound_disjoint_extrusions(&tools).unwrap().unwrap();
|
||||
assert_eq!(compound.shell_count(), 2);
|
||||
assert_eq!(compound.history().unwrap().operands, 2);
|
||||
assert!((crate::measure(&compound).unwrap().volume - 48.0).abs() < 1e-8);
|
||||
assert!(compound.validity().is_empty());
|
||||
for x in [1.0, 2.0, 2.000_000_1] {
|
||||
let touching = crate::extrude(&solidcraft_geom::Plane::XY, &[rectangle(0.0), rectangle(x)], 0.0, 5.0).unwrap();
|
||||
assert!(compound_disjoint_extrusions(&touching).unwrap().is_none());
|
||||
}
|
||||
let unsupported = [crate::sphere(Vec3::ZERO, 1.0).unwrap(), crate::sphere(Vec3::new(10.0, 0.0, 0.0), 1.0).unwrap()];
|
||||
assert!(compound_disjoint_extrusions(&unsupported).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrusion_compound_includes_arc_extrema_between_seam_vertices() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let circle = |x| Region2 { outer: Loop2::circle(Vec2::new(x, 0.0), 2.0), holes: vec![] };
|
||||
let separated = crate::extrude(&Plane::XY, &[circle(0.0), circle(8.0)], 0.0, 5.0).unwrap();
|
||||
assert!(compound_disjoint_extrusions(&separated).unwrap().is_some());
|
||||
let overlapping = crate::extrude(&Plane::XY, &[circle(0.0), circle(3.0)], 0.0, 5.0).unwrap();
|
||||
assert!(compound_disjoint_extrusions(&overlapping).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() {
|
||||
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
|
||||
@@ -513,7 +772,11 @@ fn meeting_size(a: &Body, b: &Body) -> f64 {
|
||||
};
|
||||
let (ba, bb) = (bbox(a), bbox(b));
|
||||
let s = solidcraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size();
|
||||
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
|
||||
// A closed sweep's section seams need not span its geometry. Roundoff in a flat
|
||||
// vertex box is not a thin meeting region: refining for it
|
||||
// makes millions of triangles and skips the bounded containment shortcut (#2005).
|
||||
let roundoff = a.size().max(b.size()) * 1e-12;
|
||||
if s.x > roundoff && s.y > roundoff && s.z > roundoff { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
|
||||
}
|
||||
|
||||
/// Smallest side of the box where the bounds of two meshes overlap (infinite when they don't).
|
||||
@@ -739,14 +1002,13 @@ fn boolean_whole_with_refinement(a: &Body, b: &Body, op: BoolOp, refine: bool) -
|
||||
}
|
||||
}
|
||||
// Bodies touching along one whole face (a part joined with its mirror image).
|
||||
if op == BoolOp::Union
|
||||
&& a.solid.boundaries().len() == 1
|
||||
&& b.solid.boundaries().len() == 1
|
||||
&& let Some(Ok(body)) = crate::coplanar::glue(a, b)
|
||||
{
|
||||
let (ok, _) = check(&body);
|
||||
if ok {
|
||||
return Ok(Some(body));
|
||||
if op == BoolOp::Union && a.solid.boundaries().len() == 1 {
|
||||
let glued = if b.solid.boundaries().len() == 1 { crate::coplanar::glue(a, b) } else { crate::coplanar::glue_all_caps(a, b) };
|
||||
if let Some(Ok(body)) = glued {
|
||||
let (ok, _) = check(&body);
|
||||
if ok {
|
||||
return Ok(Some(body));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bores on a common cylinder (a pad's bore around the hole it is joined over, a disc filling
|
||||
|
||||
+115
-21
@@ -181,8 +181,8 @@ pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, en
|
||||
remaining_pairs = remaining_pairs
|
||||
.checked_sub(1)
|
||||
.ok_or_else(|| KernelError::Invalid("planar extrusion separation exceeds its work budget".into()))?;
|
||||
let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) <= 1e-7)
|
||||
|| [t.start(), t.end()].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) <= 1e-7);
|
||||
let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(closest_param(t, *p)).dist(*p) <= 1e-7)
|
||||
|| [t.start(), t.end()].iter().any(|p| s.point_at(closest_param(s, *p)).dist(*p) <= 1e-7);
|
||||
if endpoints_touch || !crossings(s, t, 1e-7).is_empty() {
|
||||
return Err(KernelError::Invalid("planar material pieces touch at a boundary".into()));
|
||||
}
|
||||
@@ -765,7 +765,7 @@ fn heights_of(b: &Body, plane: &Plane) -> (f64, f64) {
|
||||
/// Does `s` run along `t` from end to end? Two lines, `s`'s ends on `t`; two arcs of one
|
||||
/// circle, `s`'s ends and middle on `t`.
|
||||
fn runs_along(s: &Seg2, t: &Seg2, tol: f64) -> bool {
|
||||
let on = |p: Vec2| t.point_at(t.closest_param(p)).dist(p) < tol;
|
||||
let on = |p: Vec2| t.point_at(closest_param(t, p)).dist(p) < tol;
|
||||
match (*s, *t) {
|
||||
(Seg2::Line { .. }, Seg2::Line { .. }) => on(s.start()) && on(s.end()),
|
||||
(Seg2::Arc { center: c, radius: r, .. }, Seg2::Arc { center: d, radius: q, .. }) => {
|
||||
@@ -840,7 +840,7 @@ fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> {
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for p in pts {
|
||||
let (u, v) = (s.closest_param(p), t.closest_param(p));
|
||||
let (u, v) = (closest_param(s, p), closest_param(t, p));
|
||||
if s.point_at(u).dist(p) < tol && t.point_at(v).dist(p) < tol {
|
||||
out.push((u, v));
|
||||
}
|
||||
@@ -871,7 +871,7 @@ fn inside_loop(lp: &Loop2, p: Vec2) -> bool {
|
||||
}
|
||||
}
|
||||
// A point on the loop keeps the half-open answer.
|
||||
if lp.segs.iter().any(|s| s.point_at(s.closest_param(p)).dist(p) < scale * 1e-9) {
|
||||
if lp.segs.iter().any(|s| s.point_at(closest_param(s, p)).dist(p) < scale * 1e-9) {
|
||||
return inside_loop_ray(lp, p);
|
||||
}
|
||||
let grazes = |q: Vec2| {
|
||||
@@ -991,12 +991,43 @@ fn inside(regions: &[Region2], p: Vec2) -> bool {
|
||||
regions.iter().any(|r| inside_loop(&r.outer, p) && !r.holes.iter().any(|h| inside_loop(h, p)))
|
||||
}
|
||||
|
||||
/// Call only after proving the point is at least `tol` from the other boundary.
|
||||
/// This sub-tolerance interior probe cannot cross that boundary, and avoids casting
|
||||
/// exactly through fitted arc/line endpoints whose heights differ by roundoff.
|
||||
fn inside_clear(regions: &[Region2], point: Vec2, tol: f64) -> bool {
|
||||
inside(regions, point + Vec2::new(tol * 0.173, tol * 0.317))
|
||||
}
|
||||
|
||||
/// Analytic projections avoid iterative searches at every planar boundary probe.
|
||||
/// Other curve types keep the shared solver introduced on main.
|
||||
fn closest_param(segment: &Seg2, point: Vec2) -> f64 {
|
||||
match *segment {
|
||||
Seg2::Line { a, b } => {
|
||||
let direction = b - a;
|
||||
let length2 = direction.dot(direction);
|
||||
if length2 > 1e-300 && length2.is_finite() { ((point - a).dot(direction) / length2).clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
Seg2::Arc { center, radius, start, sweep } if radius > 0.0 && sweep.abs() > 1e-12 && sweep.abs() <= std::f64::consts::TAU + 1e-9 => {
|
||||
let offset = point - center;
|
||||
let delta = ((offset.y.atan2(offset.x) - start) * sweep.signum()).rem_euclid(std::f64::consts::TAU);
|
||||
if delta <= sweep.abs() {
|
||||
(delta / sweep.abs()).clamp(0.0, 1.0)
|
||||
} else if segment.start().dist(point) <= segment.end().dist(point) {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
_ => segment.closest_param(point),
|
||||
}
|
||||
}
|
||||
|
||||
fn dist_to(regions: &[Region2], p: Vec2) -> f64 {
|
||||
regions
|
||||
.iter()
|
||||
.flat_map(|r| std::iter::once(&r.outer).chain(&r.holes))
|
||||
.flat_map(|l| l.segs.iter())
|
||||
.map(|s| s.point_at(s.closest_param(p)).dist(p))
|
||||
.map(|s| s.point_at(closest_param(s, p)).dist(p))
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
@@ -1043,7 +1074,7 @@ fn box_holds(b: Option<SegBox>, p: Vec2) -> bool {
|
||||
/// Distance from `p` to the nearest of `segs` whose box holds it: infinite when none does (no
|
||||
/// segment is within the tolerance of `p` then).
|
||||
fn near_dist(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2) -> f64 {
|
||||
segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(s.closest_param(p)).dist(p)).fold(f64::INFINITY, f64::min)
|
||||
segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(closest_param(s, p)).dist(p)).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// Direction of the first of `segs` through `p` (within `tol`), as it runs.
|
||||
@@ -1052,7 +1083,7 @@ fn near_dir(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2, tol: f64) -> Optio
|
||||
.iter()
|
||||
.zip(boxes)
|
||||
.filter(|(_, b)| box_holds(**b, p))
|
||||
.map(|(s, _)| (s, s.closest_param(p)))
|
||||
.map(|(s, _)| (s, closest_param(s, p)))
|
||||
.find(|(s, t)| s.point_at(*t).dist(p) < tol)?;
|
||||
s.derivative(t).normalized()
|
||||
}
|
||||
@@ -1193,7 +1224,7 @@ fn trimmed(src: &Seg2, t0: f64, t1: f64, a: Vec2, b: Vec2) -> Seg2 {
|
||||
if hi < 1.0 - 1e-12 {
|
||||
// Splitting a conic renormalises its weights, which moves its parameters: the second
|
||||
// split is found on the piece.
|
||||
let at = s.closest_param(src.point_at(hi));
|
||||
let at = closest_param(&s, src.point_at(hi));
|
||||
if at < 1.0 - 1e-12 {
|
||||
s = s.split_at(at).0;
|
||||
}
|
||||
@@ -1304,7 +1335,7 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec<Seg
|
||||
} else if p.dist(src.end()) < near {
|
||||
1.0
|
||||
} else {
|
||||
src.closest_param(p)
|
||||
closest_param(src, p)
|
||||
}
|
||||
};
|
||||
let (t0, t1) = (at(seg.start()), at(last.end()));
|
||||
@@ -1340,12 +1371,12 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec<Seg
|
||||
(Piece::Exact { src: f, t1, .. }, Piece::Exact { src: g, t0, .. }) if f != g && (inner(t1) || inner(t0)) => {
|
||||
src(f).zip(src(g)).and_then(|(x, y)| meet(x, t1, y, t0, near).map(|(a, b)| (Some(a), Some(b), x.point_at(a))))
|
||||
}
|
||||
(Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => {
|
||||
src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t1, &c, c.closest_param(joint), near).map(|(a, _)| (Some(a), None, x.point_at(a))))
|
||||
}
|
||||
(Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => {
|
||||
src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t0, &c, c.closest_param(joint), near).map(|(a, _)| (None, Some(a), x.point_at(a))))
|
||||
}
|
||||
(Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => src(f)
|
||||
.zip(carrier(&s))
|
||||
.and_then(|(x, c)| meet(x, t1, &c, closest_param(&c, joint), near).map(|(a, _)| (Some(a), None, x.point_at(a)))),
|
||||
(Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => src(f)
|
||||
.zip(carrier(&s))
|
||||
.and_then(|(x, c)| meet(x, t0, &c, closest_param(&c, joint), near).map(|(a, _)| (None, Some(a), x.point_at(a)))),
|
||||
_ => None,
|
||||
};
|
||||
// No crossing to refine (or none found): both end where the curve does.
|
||||
@@ -1449,7 +1480,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
|
||||
continue;
|
||||
}
|
||||
for p in [t.start(), t.end()] {
|
||||
let u = s.closest_param(p);
|
||||
let u = closest_param(s, p);
|
||||
if s.point_at(u).dist(p) < tol {
|
||||
events += 1;
|
||||
if events > MAX_PLANAR_EVENTS {
|
||||
@@ -1466,7 +1497,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
|
||||
continue;
|
||||
}
|
||||
for p in [s.start(), s.end()] {
|
||||
let v = t.closest_param(p);
|
||||
let v = closest_param(t, p);
|
||||
if t.point_at(v).dist(p) < tol {
|
||||
events += 1;
|
||||
if events > MAX_PLANAR_EVENTS {
|
||||
@@ -1522,7 +1553,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
|
||||
BoolOp::Cut => !same,
|
||||
}
|
||||
} else {
|
||||
let ins = inside(&b, m);
|
||||
let ins = inside_clear(&b, m, tol);
|
||||
match op {
|
||||
BoolOp::Union | BoolOp::Cut => !ins,
|
||||
BoolOp::Intersect => ins,
|
||||
@@ -1548,7 +1579,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
|
||||
if near_dist(&sa, &box_a, m) < tol {
|
||||
continue;
|
||||
}
|
||||
let ins = inside(&a, m);
|
||||
let ins = inside_clear(&a, m, tol);
|
||||
if kept.len() >= MAX_PLANAR_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
@@ -1712,7 +1743,7 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
|
||||
if p.dist(seg.start()) < tol || p.dist(seg.end()) < tol {
|
||||
return None;
|
||||
}
|
||||
let t = seg.closest_param(*p);
|
||||
let t = closest_param(seg, *p);
|
||||
(t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t)
|
||||
})
|
||||
.collect();
|
||||
@@ -1942,6 +1973,13 @@ fn sew_connected_sections(
|
||||
ends.push(u);
|
||||
}
|
||||
}
|
||||
// Welding above keeps its original encounter order. Side splitting needs
|
||||
// only the distinct stations, whose resulting parameters are sorted anyway.
|
||||
// Deduplicate in place, with bounded sorting work and no extra point list.
|
||||
if pts.len() <= MAX_PLANAR_SEGMENTS * 8 {
|
||||
pts.sort_unstable_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
|
||||
pts.dedup_by_key(|p| (p.x.to_bits(), p.y.to_bits()));
|
||||
}
|
||||
// A piece of a free-form curve whose ends weld into one point (a font's curve cut a hair from
|
||||
// its end) is no edge and has no wall: the pieces on either side meet there. (Lines and arcs
|
||||
// are left as they were: such a piece of one fails the sewing, as before.)
|
||||
@@ -2518,6 +2556,62 @@ pub(crate) fn prism_union_between_caps(a: &Body, b: &Body, op: BoolOp) -> Option
|
||||
#[cfg(test)]
|
||||
mod planar_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn shared_arc_line_endpoint_roundoff_does_not_open_a_union() {
|
||||
let a = Region2 { outer: Loop2::polygon(&[Vec2::new(-2., 0.), Vec2::new(2., 0.), Vec2::new(2., 2.), Vec2::new(-2., 2.)]), holes: vec![] };
|
||||
let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
|
||||
for epsilon in [-4e-14, 4e-14] {
|
||||
let b = Region2 {
|
||||
outer: Loop2 {
|
||||
segs: vec![
|
||||
line((1.15, epsilon), (-1.15, -epsilon)),
|
||||
Seg2::Arc { center: Vec2::new(-1.35, epsilon), radius: 0.2, start: 0., sweep: -std::f64::consts::FRAC_PI_2 },
|
||||
line((-1.35, -0.2), (-2.4, -0.2)),
|
||||
line((-2.4, -0.2), (-2.4, -1.)),
|
||||
line((-2.4, -1.), (2.4, -1.)),
|
||||
line((2.4, -1.), (2.4, -0.2)),
|
||||
line((2.4, -0.2), (1.35, -0.2)),
|
||||
Seg2::Arc {
|
||||
center: Vec2::new(1.35, -epsilon),
|
||||
radius: 0.2,
|
||||
start: -std::f64::consts::FRAC_PI_2,
|
||||
sweep: -std::f64::consts::FRAC_PI_2,
|
||||
},
|
||||
],
|
||||
},
|
||||
holes: vec![],
|
||||
};
|
||||
let result = planar_regions_boolean(std::slice::from_ref(&a), std::slice::from_ref(&b), BoolOp::Union, 1e-5).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result[0].outer.is_closed(1e-4));
|
||||
assert!((result[0].area() - a.area() - b.area()).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn analytic_projection_keeps_clamped_lines_and_wrapped_arcs() {
|
||||
let line = Seg2::Line { a: Vec2::new(2., 3.), b: Vec2::new(5., 7.) };
|
||||
for (point, expected) in [(Vec2::new(-1., -1.), 0.), (Vec2::new(3.5, 5.), 0.5), (Vec2::new(8., 11.), 1.)] {
|
||||
assert!((closest_param(&line, point) - expected).abs() < 1e-12);
|
||||
}
|
||||
for start in [-7., -0.5, 3., 7.] {
|
||||
for sweep in [-std::f64::consts::TAU, -5., -0.2, 0.2, 5., std::f64::consts::TAU] {
|
||||
let arc = Seg2::Arc { center: Vec2::new(2., -3.), radius: 4., start, sweep };
|
||||
for index in 0..128 {
|
||||
let angle = index as f64 * std::f64::consts::TAU / 128.;
|
||||
let point = Vec2::new(2., -3.) + Vec2::new(angle.cos(), angle.sin()) * 6.;
|
||||
let nearest = arc.point_at(closest_param(&arc, point));
|
||||
// Includes both signs, sweeps over pi, seam crossings and endpoint clamps.
|
||||
for sample in 0..=128 {
|
||||
assert!(nearest.dist(point) <= arc.point_at(sample as f64 / 128.).dist(point) + 1e-10);
|
||||
}
|
||||
}
|
||||
for t in [0., 0.25, 0.5, 0.75, 1.] {
|
||||
let point = arc.point_at(t);
|
||||
assert!(arc.point_at(closest_param(&arc, point)).dist(point) < 1e-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn rectangle(a: Vec2, b: Vec2) -> Region2 {
|
||||
Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve};
|
||||
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve, ParametricSurface};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use super::Ctx;
|
||||
@@ -50,6 +50,62 @@ fn rid(p: Option<&Param>, what: &str) -> R<u64> {
|
||||
p.and_then(Param::as_ref_id).ok_or_else(|| format!("{what}: expected a reference"))
|
||||
}
|
||||
|
||||
/// A contractible outer loop whose winding opposes the surface's parameter normal.
|
||||
/// Samples follow the wire through periodic seams; a loop that winds around a surface is
|
||||
/// not a disk in parameter space and must retain the file's sense.
|
||||
fn reversed_outer(surface: &mt::Surface, wire: &mt::Wire, tol: f64) -> bool {
|
||||
if let mt::Surface::RevolutedCurve(proc) = surface {
|
||||
let rc = proc.entity();
|
||||
let curve = rc.entity_curve();
|
||||
let on_axis = |p: mt::Point3| (p - rc.origin()).cross(rc.axis()).magnitude() <= tol;
|
||||
// A compact support has two legitimate sides of a closed trim (a sphere's
|
||||
// hemispheres, for example); its winding must choose the side, not repair it.
|
||||
if (on_axis(curve.front()) && on_axis(curve.back())) || curve.front().distance(curve.back()) <= tol {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let periods = truck_meshalgo::tessellation::surface_periods(surface);
|
||||
let mut first = None::<[f64; 2]>;
|
||||
let mut previous = None::<[f64; 2]>;
|
||||
let (mut area, mut magnitude) = (0.0, 0.0);
|
||||
for edge in wire.edge_iter() {
|
||||
let curve = edge.oriented_curve();
|
||||
let (a, b) = curve.range_tuple();
|
||||
for k in 0..8 {
|
||||
let point = curve.subs(a + (b - a) * f64::from(k) / 8.0);
|
||||
let hint = previous.map(|p| (p[0], p[1]));
|
||||
let valid = |&(u, v): &(f64, f64)| u.is_finite() && v.is_finite() && surface.subs(u, v).distance(point) <= tol;
|
||||
let Some((u, v)) = truck_meshalgo::tessellation::project_trim_point(surface, point, hint, tol).filter(valid) else {
|
||||
return false;
|
||||
};
|
||||
// A pole has no unique parameter in its collapsed direction. Its arbitrary
|
||||
// search result cannot determine the winding of an outer loop.
|
||||
let (du, dv) = (surface.uder(u, v), surface.vder(u, v));
|
||||
if du.cross(dv).magnitude2() <= 1e-12 * (du.magnitude2() + dv.magnitude2()).powi(2) {
|
||||
return false;
|
||||
}
|
||||
let mut p = [u, v];
|
||||
let origin = *first.get_or_insert(p);
|
||||
if let Some(prev) = previous {
|
||||
for (d, x) in p.iter_mut().enumerate() {
|
||||
if let Some(period) = periods.get(d).copied().flatten().filter(|p| *p > 0.0) {
|
||||
*x -= ((*x - prev[d]) / period).round() * period;
|
||||
}
|
||||
}
|
||||
let cross = (prev[0] - origin[0]) * (p[1] - origin[1]) - (p[0] - origin[0]) * (prev[1] - origin[1]);
|
||||
area += cross;
|
||||
magnitude += cross.abs();
|
||||
}
|
||||
previous = Some(p);
|
||||
}
|
||||
}
|
||||
let (Some(first), Some(last)) = (first, previous) else { return false };
|
||||
if periods.iter().enumerate().any(|(d, p)| p.is_some_and(|p| (first[d] - last[d]).abs() > p * 0.5)) {
|
||||
return false;
|
||||
}
|
||||
area < -16.0 * f64::EPSILON * magnitude
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
fn vertex(&mut self, cx: &Ctx, id: u64) -> R<mt::Vertex> {
|
||||
if let Some(v) = self.vertices.get(&id) {
|
||||
@@ -205,9 +261,13 @@ impl Builder {
|
||||
return Err(format!("a face with more than {MAX_BOUNDS} boundary loops"));
|
||||
}
|
||||
let mut wires = Vec::new();
|
||||
let mut outer = None;
|
||||
let mut edges = 0usize;
|
||||
for b in bounds {
|
||||
if let Some(w) = self.bound(cx, b)? {
|
||||
if entity(cx, b)?.name() == "FACE_OUTER_BOUND" {
|
||||
outer = Some(wires.len());
|
||||
}
|
||||
edges = edges.saturating_add(w.len());
|
||||
if edges > MAX_LOOP {
|
||||
return Err(format!("a face with more than {MAX_LOOP} boundary edges"));
|
||||
@@ -243,7 +303,7 @@ impl Builder {
|
||||
(0..=8).map(move |i| c.subs(t0 + (t1 - t0) * i as f64 / 8.0)).collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
let surface = if e.name() == "FACE" {
|
||||
let mut surface = if e.name() == "FACE" {
|
||||
crate::plane_fit::plane_through(&extent).map(mt::Surface::Plane).ok_or("polygonal face without a plane")?
|
||||
} else {
|
||||
let sid = rid(p.get(2), "face geometry")?;
|
||||
@@ -251,6 +311,16 @@ impl Builder {
|
||||
guard("step surface", || geom::surface(cx, sid, &extent, &seam, sense, 0).map_err(crate::KernelError::Failed))
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
// An explicit outer bound encloses the face. Some exporters give its surface the
|
||||
// opposite sense; treating that loop as a hole meshes the rest of the supporting
|
||||
// cylinder instead. Match the surface to the outer loop without reversing
|
||||
// shared topological edges. Winding loops (bands and pole seams) are left alone.
|
||||
if e.name() != "FACE" && seam.is_empty() && outer.and_then(|i| wires.get(i)).is_some_and(|w| reversed_outer(&surface, w, cx.tol)) {
|
||||
let sid = rid(p.get(2), "face geometry")?;
|
||||
let sense = p.get(3).and_then(Param::as_bool).unwrap_or(true);
|
||||
surface = guard("step surface orientation", || geom::surface(cx, sid, &extent, &seam, !sense, 0).map_err(crate::KernelError::Failed))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// Loops on a surface of revolution start away from the axis: a pole (cone apex, sphere
|
||||
// pole) has no definite rotation parameter, and the mesher takes the first point's
|
||||
// parameter as the anchor for the rest of the loop.
|
||||
|
||||
@@ -1483,3 +1483,120 @@ fn periodic_knot_tube_faces_import_and_mesh() {
|
||||
assert!(rel(m.area, area) < 0.02, "area {} (expected {area})", m.area);
|
||||
assert!(t0.elapsed().as_secs_f64() < 60.0, "took {:?}", t0.elapsed());
|
||||
}
|
||||
|
||||
/// An original analytic STEP patch: a 7 mm high, 0.002 radian strip on a 3 m
|
||||
/// cylinder. Its explicit outer bound encloses the strip even when the surface's
|
||||
/// supplied sense disagrees with the loop (#1974).
|
||||
fn shallow_cylinder_patch(sense: bool, outer: bool) -> String {
|
||||
shallow_cylinder_patch_at_radius(sense, outer, 3000.0)
|
||||
}
|
||||
|
||||
fn shallow_cylinder_patch_at_radius(sense: bool, outer: bool, r: f64) -> String {
|
||||
let (angle, h) = (6.0 / r, 7.0);
|
||||
let (x, y) = (10.0 + r * (angle.cos() - 1.0), r * angle.sin());
|
||||
let origin = 10.0 - r;
|
||||
let bound = if outer { "FACE_OUTER_BOUND" } else { "FACE_BOUND" };
|
||||
let sense = if sense { "T" } else { "F" };
|
||||
format!(
|
||||
"{HEAD}
|
||||
#1=CARTESIAN_POINT('',({origin},0.,0.));
|
||||
#2=DIRECTION('',(0.,0.,1.));
|
||||
#3=DIRECTION('',(1.,0.,0.));
|
||||
#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);
|
||||
#5=CYLINDRICAL_SURFACE('',#4,{r});
|
||||
#6=CARTESIAN_POINT('',(10.,0.,0.));
|
||||
#7=CARTESIAN_POINT('',({x},{y},0.));
|
||||
#8=CARTESIAN_POINT('',({x},{y},{h}));
|
||||
#9=CARTESIAN_POINT('',(10.,0.,{h}));
|
||||
#10=VERTEX_POINT('',#6);
|
||||
#11=VERTEX_POINT('',#7);
|
||||
#12=VERTEX_POINT('',#8);
|
||||
#13=VERTEX_POINT('',#9);
|
||||
#14=CIRCLE('',#4,{r});
|
||||
#15=CARTESIAN_POINT('',({origin},0.,{h}));
|
||||
#16=AXIS2_PLACEMENT_3D('',#15,#2,#3);
|
||||
#17=CIRCLE('',#16,{r});
|
||||
#18=VECTOR('',#2,1.);
|
||||
#19=LINE('',#7,#18);
|
||||
#20=LINE('',#6,#18);
|
||||
#21=EDGE_CURVE('',#10,#11,#14,.T.);
|
||||
#22=EDGE_CURVE('',#11,#12,#19,.T.);
|
||||
#23=EDGE_CURVE('',#12,#13,#17,.F.);
|
||||
#24=EDGE_CURVE('',#13,#10,#20,.F.);
|
||||
#25=ORIENTED_EDGE('',*,*,#21,.T.);
|
||||
#26=ORIENTED_EDGE('',*,*,#22,.T.);
|
||||
#27=ORIENTED_EDGE('',*,*,#23,.T.);
|
||||
#28=ORIENTED_EDGE('',*,*,#24,.T.);
|
||||
#29=EDGE_LOOP('',(#25,#26,#27,#28));
|
||||
#30={bound}('',#29,.T.);
|
||||
#31=ADVANCED_FACE('',(#30),#5,.{sense}.);
|
||||
#32=OPEN_SHELL('',(#31));
|
||||
#33=SHELL_BASED_SURFACE_MODEL('Strip',(#32));
|
||||
#34=SHAPE_REPRESENTATION('',(#33),$);
|
||||
{TAIL}"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_outer_bound_keeps_shallow_cylinder_patch_trimmed() {
|
||||
for (sense, radius) in [(true, 3000.), (false, 3000.), (true, 188_980.), (false, 188_980.)] {
|
||||
let imp = step_import(&shallow_cylinder_patch_at_radius(sense, true, radius)).unwrap();
|
||||
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
|
||||
let b = &imp.bodies[0];
|
||||
assert_eq!(b.file_faces, 1);
|
||||
assert!(!b.closed);
|
||||
for mesh in [
|
||||
b.body.tessellate(0.01).unwrap(),
|
||||
b.body.reference_display_mesh_angular(0.01, crate::DISPLAY_ANGLE).unwrap(),
|
||||
b.body.display_mesh_angular(0.01, crate::DISPLAY_ANGLE).unwrap(),
|
||||
] {
|
||||
assert!(!mesh.triangles.is_empty());
|
||||
assert!(mesh.bounds().diagonal() < 10.0, "sense={sense}: {:?}", mesh.bounds());
|
||||
assert!((mesh.measure().area - 42.0).abs() < 0.01, "sense={sense}: area {}", mesh.measure().area);
|
||||
assert!(mesh.normals.iter().all(|n| n.x > 0.99), "the outer loop determines the face normal");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_bound_can_still_describe_the_complement_of_a_patch() {
|
||||
let imp = step_import(&shallow_cylinder_patch(false, false)).unwrap();
|
||||
let mesh = imp.bodies[0].body.tessellate(0.1).unwrap();
|
||||
assert!(mesh.bounds().diagonal() > 5000.0, "without an explicit outer bound the loop is a hole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_outer_bound_crossing_a_closed_nurbs_seam_stays_trimmed() {
|
||||
// The same analytic strip, with its cylinder expressed as a rational B-spline.
|
||||
// Put the parameter seam halfway through the strip, independently of its edges.
|
||||
let mut points = String::new();
|
||||
let mut rows = Vec::new();
|
||||
let mut weights = Vec::new();
|
||||
let w = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let circle = [(1., 0., 1.), (1., 1., w), (0., 1., 1.), (-1., 1., w), (-1., 0., 1.), (-1., -1., w), (0., -1., 1.), (1., -1., w), (1., 0., 1.)];
|
||||
let (sin, cos) = 0.001_f64.sin_cos();
|
||||
for (i, (x, y, weight)) in circle.into_iter().enumerate() {
|
||||
let id = 1000 + i * 2;
|
||||
let (x, y) = (10.0 - 3000.0 + 3000.0 * (x * cos - y * sin), 3000.0 * (x * sin + y * cos));
|
||||
points += &format!("#{id}=CARTESIAN_POINT('',({x:.16},{y:.16},0.));\n#{}=CARTESIAN_POINT('',({x:.16},{y:.16},7.));\n", id + 1);
|
||||
rows.push(format!("(#{id},#{})", id + 1));
|
||||
weights.push(format!("({weight:.16},{weight:.16})"));
|
||||
}
|
||||
let surface = format!(
|
||||
"#5=(BOUNDED_SURFACE() B_SPLINE_SURFACE(2,1,({}),.UNSPECIFIED.,.T.,.F.,.F.) B_SPLINE_SURFACE_WITH_KNOTS((3,2,2,2,3),(2,2),(0.,1.,2.,3.,4.),(0.,1.),.UNSPECIFIED.) GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_SURFACE(({})) REPRESENTATION_ITEM('') SURFACE());",
|
||||
rows.join(","),
|
||||
weights.join(",")
|
||||
);
|
||||
for sense in [true, false] {
|
||||
let text = shallow_cylinder_patch(sense, true);
|
||||
let old = "#5=CYLINDRICAL_SURFACE('',#4,3000);";
|
||||
assert!(text.contains(old));
|
||||
let text = text.replacen(old, &surface, 1).replace(TAIL, &format!("{points}{TAIL}"));
|
||||
let imp = step_import(&text).unwrap();
|
||||
let body = &imp.bodies[0].body;
|
||||
let mesh = body.reference_display_mesh_angular(0.01, crate::DISPLAY_ANGLE).unwrap();
|
||||
assert!(!mesh.triangles.is_empty(), "{:?}", imp.warnings);
|
||||
assert!(mesh.bounds().diagonal() < 10.0, "sense={sense}: {:?}", mesh.bounds());
|
||||
assert!((mesh.measure().area - 42.0).abs() < 0.01, "sense={sense}: area {}", mesh.measure().area);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! The vendored truck-geometry's second derivatives of B-spline and NURBS surfaces (`uuder`,
|
||||
//! `vvder`) against finite differences of the surface itself, for degrees 2 to 5. Upstream's
|
||||
//! were short of the factor p − 1, so every curvature read from a cubic surface came out half its
|
||||
//! value (#763: a G2 boundary surface measured half the cylinder's curvature it does carry).
|
||||
//! These pin the vendored fix, so a refresh of the crate can't quietly bring the error back.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
use truck_modeling::ParametricSurface;
|
||||
|
||||
use crate::body::from_p3;
|
||||
|
||||
/// A degree-`p` × degree-`q` surface on a 5 × 6 net with an interior knot each way, bent in z.
|
||||
fn surface(p: usize, q: usize) -> mt::BSplineSurface<mt::Point3> {
|
||||
let knots = |d: usize, n: usize| {
|
||||
let inner: Vec<f64> = (1..n - d).map(|k| k as f64 / (n - d) as f64).collect();
|
||||
let mut k = vec![0.0; d + 1];
|
||||
k.extend(inner);
|
||||
k.extend(vec![1.0; d + 1]);
|
||||
mt::KnotVec::from(k)
|
||||
};
|
||||
let (nu, nv) = (p + 3, q + 3);
|
||||
let rows: Vec<Vec<mt::Point3>> = (0..nu)
|
||||
.map(|i| {
|
||||
(0..nv)
|
||||
.map(|j| {
|
||||
let (x, y) = (i as f64 * 2.0, j as f64 * 1.5);
|
||||
mt::Point3::new(x + 0.3 * y, y, 0.4 * x * x - 0.2 * y * y + 0.1 * x * y)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
mt::BSplineSurface::new((knots(p, nu), knots(q, nv)), rows)
|
||||
}
|
||||
|
||||
/// Second differences of `f` along the parameter line, central inside and one-sided at the ends.
|
||||
fn second(f: impl Fn(f64) -> Vec3, t: f64) -> Vec3 {
|
||||
let h = 1e-4;
|
||||
if t < 2.0 * h {
|
||||
(f(t) * 2.0 - f(t + h) * 5.0 + f(t + 2.0 * h) * 4.0 - f(t + 3.0 * h)) / (h * h)
|
||||
} else if t > 1.0 - 2.0 * h {
|
||||
(f(t) * 2.0 - f(t - h) * 5.0 + f(t - 2.0 * h) * 4.0 - f(t - 3.0 * h)) / (h * h)
|
||||
} else {
|
||||
(f(t - h) - f(t) * 2.0 + f(t + h)) / (h * h)
|
||||
}
|
||||
}
|
||||
|
||||
fn v3(x: mt::Vector3) -> Vec3 {
|
||||
Vec3::new(x.x, x.y, x.z)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bspline_surface_second_derivatives_match_finite_differences() {
|
||||
for p in 2..=5 {
|
||||
for q in 2..=5 {
|
||||
let s = surface(p, q);
|
||||
for (u, v) in [(0.0, 0.0), (0.37, 0.0), (0.5, 0.61), (1.0, 0.2), (0.13, 1.0)] {
|
||||
let uu = second(|t| from_p3(s.subs(t, v)), u);
|
||||
let vv = second(|t| from_p3(s.subs(u, t)), v);
|
||||
let scale = uu.len().max(vv.len()).max(1.0);
|
||||
assert!(v3(s.uuder(u, v)).dist(uu) < 1e-4 * scale, "degree ({p}, {q}) uuder at ({u}, {v}): {:?} vs {uu:?}", s.uuder(u, v));
|
||||
assert!(v3(s.vvder(u, v)).dist(vv) < 1e-4 * scale, "degree ({p}, {q}) vvder at ({u}, {v}): {:?} vs {vv:?}", s.vvder(u, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A rational surface's second derivatives come from its homogeneous B-spline's: they are right
|
||||
/// too.
|
||||
#[test]
|
||||
fn nurbs_surface_second_derivatives_match_finite_differences() {
|
||||
let b = surface(3, 3);
|
||||
let (ku, kv) = b.knot_vecs().clone();
|
||||
let rows: Vec<Vec<mt::Vector4>> = b
|
||||
.control_points()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, row)| {
|
||||
row.iter()
|
||||
.enumerate()
|
||||
.map(|(j, p)| {
|
||||
let w = 1.0 + 0.3 * ((i + 2 * j) % 3) as f64;
|
||||
mt::Vector4::new(p.x * w, p.y * w, p.z * w, w)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let s = mt::NurbsSurface::new(mt::BSplineSurface::new((ku, kv), rows));
|
||||
for (u, v) in [(0.0, 0.0), (0.37, 0.0), (0.5, 0.61), (1.0, 0.2)] {
|
||||
let uu = second(|t| from_p3(s.subs(t, v)), u);
|
||||
let vv = second(|t| from_p3(s.subs(u, t)), v);
|
||||
let scale = uu.len().max(vv.len()).max(1.0);
|
||||
assert!(v3(s.uuder(u, v)).dist(uu) < 1e-4 * scale, "uuder at ({u}, {v}): {:?} vs {uu:?}", s.uuder(u, v));
|
||||
assert!(v3(s.vvder(u, v)).dist(vv) < 1e-4 * scale, "vvder at ({u}, {v}): {:?} vs {vv:?}", s.vvder(u, v));
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ fn surface_of(shell: mt::Shell) -> Body {
|
||||
|
||||
/// A body from faces: a solid when they close, else a surface; with the history of the
|
||||
/// operation that made the faces from `operands` as `rec` records.
|
||||
fn body_of(faces: Vec<mt::Face>, operands: &[&mt::Solid], rec: &Rebuild) -> Result<Body> {
|
||||
pub(crate) fn body_of(faces: Vec<mt::Face>, operands: &[&mt::Solid], rec: &Rebuild) -> Result<Body> {
|
||||
let shell = mt::Shell::from(faces);
|
||||
if shell.shell_condition() == mt::ShellCondition::Closed {
|
||||
crate::record::rebuilt(mt::Solid::new_unchecked(vec![shell]), operands, rec)
|
||||
|
||||
@@ -10,7 +10,7 @@ use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, p3};
|
||||
use crate::build::{cap_and_close, interpolate_cubic, uniform_wire};
|
||||
use crate::build::{cap_and_close, close_sweep, interpolate_cubic, smooth_loft_faces, uniform_wire};
|
||||
use crate::numbered::{NumberedLoop, NumberedRegion};
|
||||
use crate::record::{self, Side};
|
||||
use crate::{KernelError, Result, guard};
|
||||
@@ -171,6 +171,43 @@ fn clean(path: &[Vec3]) -> Result<Vec<Vec3>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn closed_path(pts: &[Vec3]) -> bool {
|
||||
pts.len() > 3 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.dist(*b) <= 1e-7)
|
||||
}
|
||||
|
||||
/// The cyclic correspondence of a loop's last section to its first. Matching whole curves
|
||||
/// (rather than vertices alone) prevents sewing an incompatible twist or scale shut.
|
||||
fn closing_offset(plane: &Plane, profile: &NumberedLoop, st: &[Station]) -> Result<usize> {
|
||||
let fail =
|
||||
|| KernelError::Invalid("a sweep along a closed path must return to its starting profile; adjust the twist, end scale or rails".into());
|
||||
let (Some(first), Some(last)) = (st.first(), st.last()) else { return Err(fail()) };
|
||||
let n = profile.lp.segs.len();
|
||||
if n == 0 || n > 4096 {
|
||||
return Err(fail());
|
||||
}
|
||||
let at = |s: &Station, q: Vec2| s.at + s.apply(plane.to_world(q) - first.at);
|
||||
let tol = (profile.lp.signed_area().abs().sqrt() * 1e-7).clamp(1e-8, 1e-5);
|
||||
// Filter offsets by the first corner before comparing all the curve samples.
|
||||
let start = profile.lp.segs.first().map(Seg2::start).ok_or_else(fail)?;
|
||||
(0..n)
|
||||
.filter(|k| profile.lp.segs.get(*k).is_some_and(|s| at(last, start).dist(at(first, s.start())) <= tol))
|
||||
.find(|k| {
|
||||
profile.lp.segs.iter().enumerate().all(|(j, a)| {
|
||||
profile.lp.segs.get((j + k) % n).is_some_and(|b| {
|
||||
(0..SEG_SAMPLES).all(|i| {
|
||||
let t = i as f64 / (SEG_SAMPLES - 1) as f64;
|
||||
at(last, a.point_at(t)).dist(at(first, b.point_at(t))) <= tol
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.ok_or_else(fail)
|
||||
}
|
||||
|
||||
fn rotated_wire(wire: &mt::Wire, offset: usize) -> mt::Wire {
|
||||
wire.edge_iter().skip(offset).chain(wire.edge_iter().take(offset)).cloned().collect()
|
||||
}
|
||||
|
||||
fn turn(a: Vec3, b: Vec3) -> f64 {
|
||||
match (a.normalized(), b.normalized()) {
|
||||
(Some(a), Some(b)) => a.dot(b).clamp(-1.0, 1.0).acos(),
|
||||
@@ -506,7 +543,12 @@ pub fn sweep_path_with(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &Swe
|
||||
let v = swept_volume(plane, region.area().abs(), &st, &tangents);
|
||||
(st, v)
|
||||
};
|
||||
if let Ok(b) = smooth_walls(plane, &profile.outer, &st, expected) {
|
||||
if closed_path(&pts) {
|
||||
let offset = closing_offset(plane, &profile.outer, &st)?;
|
||||
if let Ok(b) = smooth_closed_walls(plane, &profile.outer, &st, offset, expected) {
|
||||
return Ok(b);
|
||||
}
|
||||
} else if let Ok(b) = smooth_walls(plane, &profile.outer, &st, expected) {
|
||||
return Ok(b);
|
||||
}
|
||||
}
|
||||
@@ -515,10 +557,11 @@ pub fn sweep_path_with(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &Swe
|
||||
|
||||
fn central_tangents(p: &[Vec3]) -> Result<Vec<Vec3>> {
|
||||
let n = p.len();
|
||||
let closed = closed_path(p);
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let a = p.get(i.saturating_sub(1)).copied().unwrap_or_default();
|
||||
let b = p.get((i + 1).min(n - 1)).copied().unwrap_or_default();
|
||||
let a = p.get(if closed && (i == 0 || i + 1 == n) { n - 2 } else { i.saturating_sub(1) }).copied().unwrap_or_default();
|
||||
let b = p.get(if closed && (i == 0 || i + 1 == n) { 1 } else { (i + 1).min(n - 1) }).copied().unwrap_or_default();
|
||||
(b - a).normalized().ok_or_else(|| KernelError::Invalid("the sweep path has no direction".into()))
|
||||
})
|
||||
.collect()
|
||||
@@ -535,12 +578,25 @@ fn ruled_stations(plane: &Plane, pts: &[Vec3], ctl: &SweepControl) -> Result<(Ve
|
||||
let pts = densify(pts, ctl);
|
||||
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(&pts, MAX_RULED_STATIONS) } else { pts };
|
||||
let n = pts.len();
|
||||
let closed = closed_path(&pts);
|
||||
let seg_dir = |i: usize| -> Option<Vec3> { (*pts.get(i + 1)? - *pts.get(i)?).normalized() };
|
||||
let mut tangents = Vec::with_capacity(n);
|
||||
let mut mitre = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let d_in = if i > 0 { seg_dir(i - 1) } else { None };
|
||||
let d_out = if i + 1 < n { seg_dir(i) } else { None };
|
||||
let d_in = if i > 0 {
|
||||
seg_dir(i - 1)
|
||||
} else if closed {
|
||||
seg_dir(n - 2)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let d_out = if i + 1 < n {
|
||||
seg_dir(i)
|
||||
} else if closed {
|
||||
seg_dir(0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (t, m) = match (d_in, d_out) {
|
||||
(Some(a), Some(b)) => {
|
||||
let t = (a + b).normalized().ok_or_else(|| KernelError::Invalid("the sweep path turns back on itself".into()))?;
|
||||
@@ -706,6 +762,15 @@ fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3], ctl: &SweepContr
|
||||
let pl = station_plane(plane, s0, s);
|
||||
wires.push(loops.iter().map(|l| uniform_wire(&pl, &l.lp)).collect::<Result<Vec<_>>>()?);
|
||||
}
|
||||
let closed = closed_path(&pts);
|
||||
if closed {
|
||||
let offsets = loops.iter().map(|l| closing_offset(plane, l, &st)).collect::<Result<Vec<_>>>()?;
|
||||
let first = wires.first().ok_or_else(|| KernelError::Invalid("sweep path".into()))?;
|
||||
let end: Vec<mt::Wire> = first.iter().zip(offsets).map(|(w, k)| rotated_wire(w, k)).collect();
|
||||
if let Some(last) = wires.last_mut() {
|
||||
*last = end;
|
||||
}
|
||||
}
|
||||
guard("path sweep", || {
|
||||
// Every station's wires have the loops' segments, edge for edge.
|
||||
let mut faces: Vec<Side> = Vec::new();
|
||||
@@ -716,11 +781,38 @@ fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3], ctl: &SweepContr
|
||||
faces.extend(record::ruled(&sides, a, b, &l.from, &l.from));
|
||||
}
|
||||
}
|
||||
if closed {
|
||||
return close_sweep(faces);
|
||||
}
|
||||
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("path sweep".into())) };
|
||||
cap_and_close(faces, first, last, false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Two smooth runs share their middle section and the closing section. Splitting the run
|
||||
/// avoids edges from a vertex back to itself when the profile returns without a permutation.
|
||||
fn smooth_closed_walls(plane: &Plane, profile: &NumberedLoop, st: &[Station], offset: usize, expected: f64) -> Result<Body> {
|
||||
let fail = || KernelError::Failed("closed path sweep: the walls do not close a solid".into());
|
||||
if profile.lp.segs.len() > 64 || st.len() < 7 {
|
||||
return Err(fail());
|
||||
}
|
||||
guard("closed path sweep", || {
|
||||
let first = st.first().ok_or_else(fail)?;
|
||||
let mut wires = st.iter().map(|s| uniform_wire(&station_plane(plane, first.at, s), &profile.lp)).collect::<Result<Vec<_>>>()?;
|
||||
let end = rotated_wire(wires.first().ok_or_else(fail)?, offset);
|
||||
if let Some(last) = wires.last_mut() {
|
||||
*last = end;
|
||||
}
|
||||
let middle = (wires.len() - 1) / 2;
|
||||
let numbers = vec![profile.from.clone(); wires.len()];
|
||||
let mut sides = smooth_loft_faces(wires.get(..=middle).ok_or_else(fail)?, numbers.get(..=middle).ok_or_else(fail)?, true)?;
|
||||
sides.extend(smooth_loft_faces(wires.get(middle..).ok_or_else(fail)?, numbers.get(middle..).ok_or_else(fail)?, true)?);
|
||||
let b = close_sweep(sides)?;
|
||||
let tol = (profile.lp.signed_area().abs().sqrt() * 0.05).max(1e-3);
|
||||
if b.tessellate(tol).is_ok_and(|m| (m.measure().volume - expected).abs() <= 0.15 * expected.abs()) { Ok(b) } else { Err(fail()) }
|
||||
})
|
||||
}
|
||||
|
||||
/// One B-spline wall per profile segment through the stations, capped; checked by volume.
|
||||
fn smooth_walls(plane: &Plane, profile: &NumberedLoop, st: &[Station], expected: f64) -> Result<Body> {
|
||||
let lp = &profile.lp;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Revolves of profiles with an edge on the axis that come back to it along a curve or a line
|
||||
//! (#1853): a half slot about its centreline, a quarter disc and a bullet whose arc ends on the
|
||||
//! axis, a drill point whose tip does. Their volumes are analytic, and a half slot revolved about
|
||||
//! a centreline lying in a face of a plate, as a cut, takes what it should.
|
||||
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use solidcraft_kernel::{BoolOp, boolean, box_solid, measure, revolve};
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
const R: f64 = 3.0;
|
||||
|
||||
fn line(a: (f64, f64), b: (f64, f64)) -> Seg2 {
|
||||
Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) }
|
||||
}
|
||||
|
||||
/// A quarter of a circle of radius `R` about `c`, counter-clockwise from `start`.
|
||||
fn quarter(c: (f64, f64), start: f64) -> Seg2 {
|
||||
Seg2::Arc { center: Vec2::new(c.0, c.1), radius: R, start, sweep: FRAC_PI_2 }
|
||||
}
|
||||
|
||||
fn region(segs: Vec<Seg2>) -> Region2 {
|
||||
Region2 { outer: Loop2 { segs }, holes: vec![] }
|
||||
}
|
||||
|
||||
/// Half a slot (radius `R`, centres 14 apart) cut along its centreline on the x axis.
|
||||
fn half_slot() -> Region2 {
|
||||
region(vec![line((0.0, 0.0), (20.0, 0.0)), quarter((17.0, 0.0), 0.0), line((17.0, R), (3.0, R)), quarter((3.0, 0.0), FRAC_PI_2)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolves_that_end_on_the_axis_along_a_curve_have_their_volume() {
|
||||
let sphere = 4.0 / 3.0 * PI * R.powi(3);
|
||||
let cylinder = PI * R * R * 14.0;
|
||||
let shapes = [
|
||||
("half slot", half_slot(), cylinder + sphere),
|
||||
("quarter disc", region(vec![line((-R, 0.0), (0.0, 0.0)), line((0.0, 0.0), (0.0, R)), quarter((0.0, 0.0), FRAC_PI_2)]), sphere / 2.0),
|
||||
(
|
||||
"bullet",
|
||||
region(vec![line((0.0, 0.0), (17.0, 0.0)), line((17.0, 0.0), (17.0, R)), line((17.0, R), (3.0, R)), quarter((3.0, 0.0), FRAC_PI_2)]),
|
||||
cylinder + sphere / 2.0,
|
||||
),
|
||||
// A drill point: a line coming back to the axis (a full turn keeps truck's cone).
|
||||
(
|
||||
"drill point",
|
||||
region(vec![line((0.0, 0.0), (20.0, 0.0)), line((20.0, 0.0), (20.0, R)), line((20.0, R), (3.0, R)), line((3.0, R), (0.0, 0.0))]),
|
||||
PI * R * R * 17.0 + PI * R * R * 3.0 / 3.0,
|
||||
),
|
||||
];
|
||||
// The plane through the origin, and one moved and turned away from it.
|
||||
let planes = [Plane::XY, Plane { origin: Vec3::new(5.0, 7.0, 10.0), x: Vec3::Y, y: Vec3::Z }];
|
||||
for (name, r, full) in &shapes {
|
||||
for plane in &planes {
|
||||
for turn in [FRAC_PI_2, PI, -PI, TAU] {
|
||||
let body = revolve(plane, std::slice::from_ref(r), Vec2::ZERO, Vec2::new(1.0, 0.0), turn).unwrap().pop().unwrap();
|
||||
assert!(body.validity().is_empty(), "{name}, {turn}: {:?}", body.validity());
|
||||
let want = full * turn.abs() / TAU;
|
||||
let got = measure(&body).unwrap().volume;
|
||||
assert!((got - want).abs() < 1e-3 * want, "{name} turned {turn} on {:?}: {got} instead of {want}", plane.origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_half_slot_revolved_about_a_centreline_in_a_face_cuts_it() {
|
||||
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 20.0, 10.0)).unwrap();
|
||||
let whole = measure(&plate).unwrap().volume;
|
||||
// The half slot from x = 10 to 30 with its centreline on y = 10 of the top face; the plate
|
||||
// holds the half of the capsule below it.
|
||||
let inside = (PI * R * R * 14.0 + 4.0 / 3.0 * PI * R.powi(3)) / 2.0;
|
||||
let r = half_slot();
|
||||
// The sketch plane facing out of the plate and into it (its y running the other way).
|
||||
for flip in [1.0, -1.0] {
|
||||
let plane = Plane { origin: Vec3::new(10.0, 10.0, 10.0), x: Vec3::X, y: Vec3::Y * flip };
|
||||
for turn in [PI, -PI, TAU] {
|
||||
let tool = revolve(&plane, std::slice::from_ref(&r), Vec2::ZERO, Vec2::new(1.0, 0.0), turn).unwrap().pop().unwrap();
|
||||
// A half turn into the plate, or a full one, cuts a groove; a half turn out of it misses.
|
||||
let into = turn.abs() > PI || tool.tessellate(0.01).unwrap().bounds().min.z < 9.0;
|
||||
let cut = boolean(&plate, &tool, BoolOp::Cut).unwrap().unwrap();
|
||||
assert!(cut.validity().is_empty(), "flip {flip}, {turn}: {:?}", cut.validity());
|
||||
let removed = whole - measure(&cut).unwrap().volume;
|
||||
let want = if into { inside } else { 0.0 };
|
||||
assert!((removed - want).abs() < 1e-3 * inside, "flip {flip}, turned {turn}: removed {removed} instead of {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Original analytic surfaces exercising trim projection (#1974), without an external CAD fixture.
|
||||
use truck_meshalgo::prelude::*;
|
||||
use truck_modeling::{self as mt, BoundedCurve, InnerSpace};
|
||||
|
||||
fn mesh(surface: mt::Surface, curves: Vec<mt::Curve>) -> PolygonMesh {
|
||||
let vertices: Vec<_> = curves.iter().map(|c| mt::Vertex::new(c.front())).collect();
|
||||
let count = vertices.len();
|
||||
let edges: Vec<_> = curves.into_iter().enumerate().map(|(i, c)| mt::Edge::new_unchecked(&vertices[i], &vertices[(i + 1) % count], c)).collect();
|
||||
let face = mt::Face::new_unchecked(vec![edges.into()], surface);
|
||||
let shell = mt::Shell::from(vec![face]).robust_triangulation(0.001);
|
||||
let polygon = shell.face_iter().next().unwrap().surface().unwrap();
|
||||
assert!(polygon.faces().triangle_iter().next().is_some(), "the trimmed face must remain visible");
|
||||
polygon
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approximate_boundary_does_not_escape_a_bounded_spline() {
|
||||
// S(u,v) = (50u + 10v, .001v, 10u² + 4v), 0 <= u,v <= 1.
|
||||
// Near u=1 its tangents are almost parallel. A slightly approximate top edge
|
||||
// sends an undamped search hinted from the preceding point to u>1.
|
||||
let p = mt::Point3::new;
|
||||
let surface = mt::BSplineSurface::new(
|
||||
(mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)),
|
||||
vec![vec![p(0., 0., 0.), p(10., 0.001, 4.)], vec![p(25., 0., 0.), p(35., 0.001, 4.)], vec![p(50., 0., 10.), p(60., 0.001, 14.)]],
|
||||
);
|
||||
let curves = vec![
|
||||
mt::BSplineCurve::new(mt::KnotVec::bezier_knot(2), vec![p(60., 0.001, 14.), p(35., 0., 4.), p(10., 0.001, 4.)]).into(),
|
||||
mt::Line(p(10., 0.001, 4.), p(0., 0., 0.)).into(),
|
||||
mt::BSplineCurve::new(mt::KnotVec::bezier_knot(2), vec![p(0., 0., 0.), p(25., 0., 0.), p(50., 0., 10.)]).into(),
|
||||
mt::Line(p(50., 0., 10.), p(60., 0.001, 14.)).into(),
|
||||
];
|
||||
let mesh = mesh(mt::Surface::BSplineSurface(surface), curves);
|
||||
for uv in mesh.uv_coords() {
|
||||
assert!((-1e-6..=1. + 1e-6).contains(&uv.x) && (-1e-6..=1. + 1e-6).contains(&uv.y), "parameter {uv:?}");
|
||||
}
|
||||
assert!(mesh.positions().iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trim_crossing_a_closed_nurbs_seam_is_a_small_cylinder_patch() {
|
||||
let (radius, angle, bottom, top) = (10.0_f64, 0.1_f64, 2.0, 5.0);
|
||||
let w = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let circle = [(1., 0., 1.), (1., 1., w), (0., 1., 1.), (-1., 1., w), (-1., 0., 1.), (-1., -1., w), (0., -1., 1.), (1., -1., w), (1., 0., 1.)];
|
||||
let ctrl = circle
|
||||
.iter()
|
||||
.map(|&(x, y, w)| vec![mt::Vector4::new(radius * x * w, radius * y * w, 0., w), mt::Vector4::new(radius * x * w, radius * y * w, 10. * w, w)])
|
||||
.collect();
|
||||
let surface = mt::NurbsSurface::new(mt::BSplineSurface::new(
|
||||
(mt::KnotVec::from(vec![0., 0., 0., 1., 1., 2., 2., 3., 3., 4., 4., 4.]), mt::KnotVec::bezier_knot(1)),
|
||||
ctrl,
|
||||
));
|
||||
let at = |theta: f64, z| mt::Point3::new(radius * theta.cos(), radius * theta.sin(), z);
|
||||
let arc = |z, reverse| {
|
||||
let w = angle.cos();
|
||||
let mut ctrl = vec![at(-angle, z).to_homogeneous(), mt::Vector4::new(radius, 0., z * w, w), at(angle, z).to_homogeneous()];
|
||||
if reverse {
|
||||
ctrl.reverse();
|
||||
}
|
||||
mt::NurbsCurve::new(mt::BSplineCurve::new(mt::KnotVec::bezier_knot(2), ctrl)).into()
|
||||
};
|
||||
let curves = vec![
|
||||
arc(bottom, false),
|
||||
mt::Line(at(angle, bottom), at(angle, top)).into(),
|
||||
arc(top, true),
|
||||
mt::Line(at(-angle, top), at(-angle, bottom)).into(),
|
||||
];
|
||||
let mesh = mesh(mt::Surface::NurbsSurface(surface), curves);
|
||||
for p in mesh.positions() {
|
||||
assert!(p.x >= radius * angle.cos() - 1e-6 && p.x <= radius + 1e-6, "{p:?}");
|
||||
assert!(p.y.abs() <= radius * angle.sin() + 1e-6 && (bottom - 1e-6..=top + 1e-6).contains(&p.z), "{p:?}");
|
||||
}
|
||||
let area: f64 = mesh
|
||||
.faces()
|
||||
.triangle_iter()
|
||||
.map(|t| {
|
||||
let [a, b, c] = [0, 1, 2].map(|i| mesh.positions()[t[i].pos]);
|
||||
(b - a).cross(c - a).magnitude() * 0.5
|
||||
})
|
||||
.sum();
|
||||
assert!((area - radius * 2.0 * angle * (top - bottom)).abs() < 0.01, "area {area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_affine_planes_unit_chart_does_not_clip_a_trim() {
|
||||
let p = mt::Point3::new;
|
||||
let points = [p(10., 20., 0.), p(13., 20., 0.), p(13., 22., 0.), p(10., 22., 0.)];
|
||||
let curves = (0..4).map(|i| mt::Line(points[i], points[(i + 1) % 4]).into()).collect();
|
||||
let mesh = mesh(mt::Plane::new(p(0., 0., 0.), p(1., 0., 0.), p(0., 1., 0.)).into(), curves);
|
||||
for p in mesh.positions() {
|
||||
assert!((10. - 1e-6..=13. + 1e-6).contains(&p.x) && (20. - 1e-6..=22. + 1e-6).contains(&p.y), "{p:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Loose ends of a sketch's profile curves, for the sketch to show where a profile can fail.
|
||||
//!
|
||||
//! Profiles are read from the geometry: ends that lie together close a loop whether or not a
|
||||
//! constraint ties them (see `profiles`). Two kinds of end are worth showing:
|
||||
//! - an **open end** touches no other profile curve, so no profile runs through it;
|
||||
//! - an **untied end** touches another profile curve only by position: the loop closes now, but
|
||||
//! nothing keeps it closed when a dimension changes or a point is dragged.
|
||||
//!
|
||||
//! An end is tied when it is shared with another curve's end, joined to one by coincident
|
||||
//! constraints, or held on a curve (point on curve, midpoint).
|
||||
|
||||
use solidcraft_geom::{Seg2, Vec2};
|
||||
|
||||
use crate::model::{ConstraintKind, CurveKind, Sketch};
|
||||
use crate::union_find::{find, union};
|
||||
|
||||
/// How close an end has to be to another curve to touch it: the distance at which profile
|
||||
/// finding joins and splits curves.
|
||||
pub const END_TOL: f64 = 1e-5;
|
||||
|
||||
/// Ends looked at, at most: a larger sketch reports nothing rather than taking long.
|
||||
const MAX_ENDS: usize = 100_000;
|
||||
|
||||
/// What an untied end touches.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EndTouch {
|
||||
/// Another curve's end (a point index).
|
||||
Point(usize),
|
||||
/// Another curve, between its ends (a curve index).
|
||||
Curve(usize),
|
||||
}
|
||||
|
||||
/// An end that touches another curve without being tied to it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct UntiedEnd {
|
||||
/// The end's point index.
|
||||
pub point: usize,
|
||||
/// The curve it is an end of.
|
||||
pub curve: usize,
|
||||
pub touches: EndTouch,
|
||||
}
|
||||
|
||||
/// The open and untied ends of a sketch's drawn profile curves (point indices).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EndReport {
|
||||
pub open: Vec<usize>,
|
||||
pub untied: Vec<UntiedEnd>,
|
||||
}
|
||||
|
||||
impl EndReport {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.open.is_empty() && self.untied.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Curves profiles are made of (the ones `find_profiles` reads).
|
||||
fn profile_curve(sk: &Sketch, ci: usize) -> bool {
|
||||
sk.curves.get(ci).is_some_and(|c| !(c.construction || c.centerline || c.infinite))
|
||||
}
|
||||
|
||||
/// A box around a segment (conservative for curved ones).
|
||||
fn seg_box(s: &Seg2) -> (Vec2, Vec2) {
|
||||
let hull = |pts: &[Vec2]| {
|
||||
pts.iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| {
|
||||
(Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)))
|
||||
})
|
||||
};
|
||||
match *s {
|
||||
Seg2::Line { a, b } => hull(&[a, b]),
|
||||
Seg2::Arc { center, radius, .. } => {
|
||||
let r = Vec2::new(radius.abs(), radius.abs());
|
||||
(center - r, center + r)
|
||||
}
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => hull(&[p0, p1, p2, p3]),
|
||||
Seg2::Conic { a, apex, b, .. } => hull(&[a, apex, b]),
|
||||
Seg2::Bezier { n, p } => hull(p.get(..=usize::from(n)).unwrap_or(p.as_slice())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The open and untied ends of the drawn profile curves (lines, arcs and open free-form curves
|
||||
/// that are not construction, projected or text). Positions are the sketch's current ones.
|
||||
pub fn loose_ends(sk: &Sketch) -> EndReport {
|
||||
let n = sk.points.len();
|
||||
// Coincident constraints join points into one place.
|
||||
let mut group: Vec<usize> = (0..n).collect();
|
||||
for c in &sk.constraints {
|
||||
if let ConstraintKind::Coincident { p, q } = c.kind {
|
||||
union(&mut group, p, q);
|
||||
}
|
||||
}
|
||||
// Per place: how many profile curve ends meet there, and whether a point there is held
|
||||
// on a profile curve.
|
||||
let mut ends_at = vec![0usize; n];
|
||||
let mut held = vec![false; n];
|
||||
let mut ends: Vec<(usize, usize)> = Vec::new();
|
||||
for (ci, c) in sk.curves.iter().enumerate() {
|
||||
if !profile_curve(sk, ci) {
|
||||
continue;
|
||||
}
|
||||
let Some((a, b)) = c.kind.ends() else { continue };
|
||||
for p in [a, b] {
|
||||
let g = find(&mut group, p);
|
||||
if let Some(k) = ends_at.get_mut(g) {
|
||||
*k += 1;
|
||||
}
|
||||
}
|
||||
// Lines shorter than profile finding keeps are left out of profiles too.
|
||||
let degenerate = matches!(c.kind, CurveKind::Line { .. }) && sk.point(a).zip(sk.point(b)).is_some_and(|(pa, pb)| pa.dist(pb) <= END_TOL);
|
||||
if c.link.is_none() && !degenerate {
|
||||
ends.push((a, ci));
|
||||
if b != a {
|
||||
ends.push((b, ci));
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in &sk.constraints {
|
||||
let on = match c.kind {
|
||||
ConstraintKind::PointOnCurve { p, c } | ConstraintKind::Midpoint { p, l: c } if profile_curve(sk, c) => Some(p),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(p) = on {
|
||||
let g = find(&mut group, p);
|
||||
if let Some(h) = held.get_mut(g) {
|
||||
*h = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ends.len() > MAX_ENDS {
|
||||
return EndReport::default();
|
||||
}
|
||||
// Ends tied to nothing: the ones to look at by position.
|
||||
let mut free: Vec<(usize, usize, Vec2)> = Vec::new();
|
||||
for &(p, ci) in &ends {
|
||||
let g = find(&mut group, p);
|
||||
if ends_at.get(g).copied().unwrap_or(0) >= 2 || held.get(g).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if let Some(at) = sk.point(p).filter(|v| v.x.is_finite() && v.y.is_finite()) {
|
||||
free.push((p, ci, at));
|
||||
}
|
||||
}
|
||||
if free.is_empty() {
|
||||
return EndReport::default();
|
||||
}
|
||||
free.sort_by(|a, b| a.2.x.total_cmp(&b.2.x));
|
||||
// What each free end touches: another curve's end first, else another curve's span.
|
||||
let mut touch: Vec<Option<EndTouch>> = vec![None; free.len()];
|
||||
for (ci, c) in sk.curves.iter().enumerate() {
|
||||
if !profile_curve(sk, ci) {
|
||||
continue;
|
||||
}
|
||||
let curve_ends = c.kind.ends();
|
||||
for seg in sk.segs(ci) {
|
||||
let (lo, hi) = seg_box(&seg);
|
||||
let first = free.partition_point(|f| f.2.x < lo.x - END_TOL);
|
||||
for (k, &(p, own, at)) in free.iter().enumerate().skip(first) {
|
||||
if at.x > hi.x + END_TOL {
|
||||
break;
|
||||
}
|
||||
if at.y < lo.y - END_TOL || at.y > hi.y + END_TOL || matches!(touch.get(k), Some(Some(EndTouch::Point(_)))) {
|
||||
continue;
|
||||
}
|
||||
// Another end of a curve (its own other end too: a loop drawn as one curve).
|
||||
let at_end = curve_ends.and_then(|(a, b)| [a, b].into_iter().find(|&q| q != p && sk.point(q).is_some_and(|v| v.dist(at) <= END_TOL)));
|
||||
let found = match at_end {
|
||||
Some(q) => Some(EndTouch::Point(q)),
|
||||
None if ci != own && seg.point_at(seg.closest_param(at)).dist(at) <= END_TOL => Some(EndTouch::Curve(ci)),
|
||||
None => None,
|
||||
};
|
||||
if let (Some(found), Some(slot)) = (found, touch.get_mut(k))
|
||||
&& (slot.is_none() || matches!(found, EndTouch::Point(_)))
|
||||
{
|
||||
*slot = Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Free ends that touch each other are one place, however many meet there: each is tied to
|
||||
// the place's first end, which is tied to what it touches outside the place (if anything).
|
||||
let index: std::collections::HashMap<usize, usize> = free.iter().enumerate().map(|(k, f)| (f.0, k)).collect();
|
||||
let mut place: Vec<usize> = (0..free.len()).collect();
|
||||
for (k, t) in touch.iter().enumerate() {
|
||||
if let Some(EndTouch::Point(q)) = t
|
||||
&& let Some(&j) = index.get(q)
|
||||
{
|
||||
union(&mut place, k, j);
|
||||
}
|
||||
}
|
||||
let mut first: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
|
||||
for (k, f) in free.iter().enumerate() {
|
||||
let root = find(&mut place, k);
|
||||
let e = first.entry(root).or_insert(f.0);
|
||||
*e = (*e).min(f.0);
|
||||
}
|
||||
let mut report = EndReport::default();
|
||||
for (k, &(p, curve, _)) in free.iter().enumerate() {
|
||||
let Some(t) = touch.get(k).copied().flatten() else {
|
||||
report.open.push(p);
|
||||
continue;
|
||||
};
|
||||
let root = find(&mut place, k);
|
||||
let anchor = first.get(&root).copied().unwrap_or(p);
|
||||
let touches = match t {
|
||||
_ if anchor != p => EndTouch::Point(anchor),
|
||||
// The place's first end: tied to what it touches outside the place.
|
||||
EndTouch::Point(q) if index.contains_key(&q) => continue,
|
||||
t => t,
|
||||
};
|
||||
report.untied.push(UntiedEnd { point: p, curve, touches });
|
||||
}
|
||||
report.open.sort_unstable();
|
||||
report.open.dedup();
|
||||
report.untied.sort_by_key(|u| u.point);
|
||||
report.untied.dedup_by_key(|u| u.point);
|
||||
report
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Open and untied ends of profile curves.
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use crate::{ConstraintKind, EndTouch, Sketch, find_profiles, loose_ends};
|
||||
|
||||
fn v(x: f64, y: f64) -> Vec2 {
|
||||
Vec2::new(x, y)
|
||||
}
|
||||
|
||||
/// A closed polyline whose corners are each one shared point.
|
||||
fn shared_square(sk: &mut Sketch) -> Vec<usize> {
|
||||
let pts: Vec<usize> = [v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
|
||||
(0..4).map(|i| sk.add_line_pts(pts[i], pts[(i + 1) % 4], None).unwrap()).collect()
|
||||
}
|
||||
|
||||
/// A square drawn as four separate lines: every corner is two points at one place.
|
||||
fn loose_square(sk: &mut Sketch) -> Vec<usize> {
|
||||
let c = [v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)];
|
||||
(0..4).map(|i| sk.add_line(c[i], c[(i + 1) % 4], None, None, None).unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_corners_are_tied() {
|
||||
let mut sk = Sketch::new();
|
||||
shared_square(&mut sk);
|
||||
assert!(loose_ends(&sk).is_empty(), "{:?}", loose_ends(&sk));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_together_by_position_only_are_untied() {
|
||||
let mut sk = Sketch::new();
|
||||
loose_square(&mut sk);
|
||||
// The square closes (profiles are read from positions)...
|
||||
assert_eq!(find_profiles(&sk).len(), 1);
|
||||
// ...but nothing keeps it closed: four corners, each reported once.
|
||||
let r = loose_ends(&sk);
|
||||
assert!(r.open.is_empty(), "{r:?}");
|
||||
assert_eq!(r.untied.len(), 4, "{r:?}");
|
||||
assert!(r.untied.iter().all(|u| matches!(u.touches, EndTouch::Point(_))), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_constraints_tie_corners() {
|
||||
let mut sk = Sketch::new();
|
||||
let lines = loose_square(&mut sk);
|
||||
for i in 0..4 {
|
||||
let end = sk.curves[lines[i]].kind.ends().unwrap().1;
|
||||
let start = sk.curves[lines[(i + 1) % 4]].kind.ends().unwrap().0;
|
||||
sk.add_constraint(ConstraintKind::Coincident { p: end, q: start }, None).unwrap();
|
||||
}
|
||||
assert!(loose_ends(&sk).is_empty(), "{:?}", loose_ends(&sk));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_is_two_open_ends() {
|
||||
let mut sk = Sketch::new();
|
||||
let a = sk.add_point(v(0.0, 0.0), None).unwrap();
|
||||
let b = sk.add_point(v(10.0, 0.0), None).unwrap();
|
||||
let c = sk.add_point(v(10.0, 10.0), None).unwrap();
|
||||
sk.add_line_pts(a, b, None).unwrap();
|
||||
sk.add_line_pts(b, c, None).unwrap();
|
||||
// The closing line stops 0.01 mm short of the start.
|
||||
let d = sk.add_point(v(0.0, 0.01), None).unwrap();
|
||||
sk.add_line_pts(c, d, None).unwrap();
|
||||
assert!(find_profiles(&sk).is_empty());
|
||||
let r = loose_ends(&sk);
|
||||
assert_eq!(r.open, vec![a, d], "{r:?}");
|
||||
assert!(r.untied.is_empty(), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_end_on_another_curve_is_untied_until_held() {
|
||||
let mut sk = Sketch::new();
|
||||
shared_square(&mut sk);
|
||||
// A divider from the bottom side's middle to the top side's middle, by position only.
|
||||
let l = sk.add_line(v(5.0, 0.0), v(5.0, 10.0), None, None, None).unwrap();
|
||||
assert_eq!(find_profiles(&sk).len(), 2);
|
||||
let r = loose_ends(&sk);
|
||||
assert_eq!(r.untied.len(), 2, "{r:?}");
|
||||
assert!(r.untied.iter().all(|u| u.curve == l && matches!(u.touches, EndTouch::Curve(_))), "{r:?}");
|
||||
// Held on the sides, they are tied.
|
||||
for u in r.untied {
|
||||
if let EndTouch::Curve(c) = u.touches {
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p: u.point, c }, None).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(loose_ends(&sk).is_empty(), "{:?}", loose_ends(&sk));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_lines_and_circles_have_no_loose_ends() {
|
||||
let mut sk = Sketch::new();
|
||||
let l = sk.add_line(v(0.0, 0.0), v(10.0, 0.0), None, None, None).unwrap();
|
||||
sk.curves[l].construction = true;
|
||||
sk.add_circle(v(20.0, 0.0), 3.0, None, None).unwrap();
|
||||
assert!(loose_ends(&sk).is_empty(), "{:?}", loose_ends(&sk));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_line_has_two_open_ends() {
|
||||
let mut sk = Sketch::new();
|
||||
let l = sk.add_line(v(0.0, 0.0), v(10.0, 0.0), None, None, None).unwrap();
|
||||
let (a, b) = sk.curves[l].kind.ends().unwrap();
|
||||
assert_eq!(loose_ends(&sk).open, vec![a.min(b), a.max(b)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_ends_meeting_by_position_are_one_place_tied_at_once() {
|
||||
let mut sk = Sketch::new();
|
||||
for to in [v(0.0, 0.0), v(20.0, 0.0), v(10.0, 10.0)] {
|
||||
sk.add_line(to, v(10.0, 0.0), None, None, None).unwrap();
|
||||
}
|
||||
let r = loose_ends(&sk);
|
||||
// Two of the three need tying, both to the third.
|
||||
assert_eq!(r.untied.len(), 2, "{r:?}");
|
||||
let anchor = match r.untied[0].touches {
|
||||
EndTouch::Point(q) => q,
|
||||
EndTouch::Curve(_) => usize::MAX,
|
||||
};
|
||||
assert!(r.untied.iter().all(|u| u.touches == EndTouch::Point(anchor) && u.point != anchor), "{r:?}");
|
||||
for u in &r.untied {
|
||||
sk.add_constraint(ConstraintKind::Coincident { p: u.point, q: anchor }, None).unwrap();
|
||||
}
|
||||
assert!(loose_ends(&sk).untied.is_empty(), "{:?}", loose_ends(&sk));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! A solve with geometry held moves the rest as little as it can.
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use crate::{Analysis, ConstraintKind, Hold, Sketch, solve_with};
|
||||
|
||||
fn v(x: f64, y: f64) -> Vec2 {
|
||||
Vec2::new(x, y)
|
||||
}
|
||||
|
||||
/// A rectangle with a divider whose ends are held on the bottom and top sides (point on curve).
|
||||
/// Dragging the top-left corner tilts the top side: the divider's top end slides along with it,
|
||||
/// the far corner stays.
|
||||
#[test]
|
||||
fn a_point_held_on_a_line_slides_instead_of_swinging_the_line() {
|
||||
let mut sk = Sketch::new();
|
||||
let c: Vec<usize> = [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
|
||||
let sides: Vec<usize> = (0..4).map(|i| sk.add_line_pts(c[i], c[(i + 1) % 4], None).unwrap()).collect();
|
||||
let bottom = sk.add_point(v(20.0, 0.0), None).unwrap();
|
||||
let top = sk.add_point(v(20.0, 30.0), None).unwrap();
|
||||
sk.add_line_pts(bottom, top, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p: bottom, c: sides[0] }, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p: top, c: sides[2] }, None).unwrap();
|
||||
let before: Vec<Vec2> = sk.points.iter().map(|p| p.pos).collect();
|
||||
sk.points[c[3]].pos = v(1.0, 31.0);
|
||||
let rep = solve_with(&mut sk, &Hold { points: vec![c[3]], radii: vec![] }, Some(&before), Analysis::StatusOnly, None);
|
||||
assert!(rep.ok(), "{rep:?}");
|
||||
let moved = |i: usize| sk.points[i].pos.dist(before[i]);
|
||||
// The top side through (1, 31) and (40, 30) passes x = 20 at y ≈ 30.51: the divider's end
|
||||
// needs about half a millimetre. The far corner needs nothing.
|
||||
assert!(moved(top) < 0.6, "divider end moved {}", moved(top));
|
||||
assert!(moved(c[2]) < 0.3, "far corner moved {}", moved(c[2]));
|
||||
assert!(moved(c[1]) < 1e-6 && moved(bottom) < 1e-6, "the bottom has no reason to move");
|
||||
}
|
||||
|
||||
/// The same rectangle, dragging the top-right corner down: the top side turns about its other
|
||||
/// corner, which stays, and the divider's end slides down with it.
|
||||
#[test]
|
||||
fn the_far_corner_stays_when_the_side_a_point_is_held_on_turns() {
|
||||
let mut sk = Sketch::new();
|
||||
let c: Vec<usize> = [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
|
||||
let sides: Vec<usize> = (0..4).map(|i| sk.add_line_pts(c[i], c[(i + 1) % 4], None).unwrap()).collect();
|
||||
let bottom = sk.add_point(v(20.0, 0.0), None).unwrap();
|
||||
let top = sk.add_point(v(20.0, 30.0), None).unwrap();
|
||||
sk.add_line_pts(bottom, top, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p: bottom, c: sides[0] }, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p: top, c: sides[2] }, None).unwrap();
|
||||
// Down in steps, as a live drag goes: each solve starts from the last.
|
||||
for k in 1..=10 {
|
||||
let before: Vec<Vec2> = sk.points.iter().map(|p| p.pos).collect();
|
||||
sk.points[c[2]].pos = v(40.0, 30.0 - k as f64);
|
||||
let rep = solve_with(&mut sk, &Hold { points: vec![c[2]], radii: vec![] }, Some(&before), Analysis::StatusOnly, None);
|
||||
assert!(rep.ok(), "{rep:?}");
|
||||
}
|
||||
let corner = sk.points[c[3]].pos;
|
||||
assert!(corner.dist(v(0.0, 30.0)) < 0.1, "the far corner went to {corner:?}");
|
||||
// On the turned side, near where it was.
|
||||
let (end, a, b) = (sk.points[top].pos, corner, sk.points[c[2]].pos);
|
||||
let off_side = ((b - a).cross(end - a) / a.dist(b)).abs();
|
||||
assert!(off_side < 1e-6 && (end.x - 20.0).abs() < 1.0, "the divider's end is at {end:?}");
|
||||
}
|
||||
@@ -20,6 +20,7 @@ mod construct;
|
||||
mod curves;
|
||||
mod decompose;
|
||||
mod dims;
|
||||
mod ends;
|
||||
mod fonts;
|
||||
mod freeform_offset;
|
||||
pub mod linalg;
|
||||
@@ -44,6 +45,7 @@ pub use curves::{
|
||||
};
|
||||
pub use decompose::Budget;
|
||||
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, dim_layout_reaching, dim_reach, encode_text};
|
||||
pub use ends::{END_TOL, EndReport, EndTouch, UntiedEnd, loose_ends};
|
||||
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, installed_families, set_font_file_check};
|
||||
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
|
||||
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, TextExprs, sketch_axis_geom};
|
||||
@@ -74,8 +76,12 @@ mod construct_tests;
|
||||
#[cfg(test)]
|
||||
mod continuation_tests;
|
||||
#[cfg(test)]
|
||||
mod ends_tests;
|
||||
#[cfg(test)]
|
||||
mod font_tests;
|
||||
#[cfg(test)]
|
||||
mod least_motion_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod thin_tests;
|
||||
|
||||
@@ -123,7 +123,7 @@ pub(super) fn dependent_blocks(
|
||||
/// dimension (it constrains nothing) or an unknown index.
|
||||
pub fn constraint_residual(sk: &Sketch, ci: usize) -> Option<f64> {
|
||||
let c = sk.constraints.get(ci).filter(|c| !c.driven)?;
|
||||
let fixed = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
|
||||
let fixed = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
|
||||
let on_curve = points_on_curves(sk);
|
||||
let b = match super::touch_point(sk, &c.kind, &on_curve) {
|
||||
Some(p) => Block::TangentAt(ci, &c.kind, p),
|
||||
|
||||
+33
-11
@@ -36,6 +36,9 @@ const MAX_COMPONENT_VARS: usize = 4_000;
|
||||
const TOL: f64 = 1e-9;
|
||||
/// A solution is accepted when every residual is below this (mm).
|
||||
const ACCEPT: f64 = 1e-6;
|
||||
/// How much less it costs a solve to move a point held on a curve than any other point: such a
|
||||
/// point is placed relative to the curve, so it follows the curve rather than the curve it.
|
||||
const SLIDE_WEIGHT: f64 = 0.01;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -107,6 +110,9 @@ struct Layout {
|
||||
n: usize,
|
||||
/// The point coordinates' variables: `0..points`.
|
||||
points: usize,
|
||||
/// Per variable: a coordinate of a point held on a curve (point on curve, midpoint), which
|
||||
/// a solve slides along it before moving the curve (see [`SLIDE_WEIGHT`]).
|
||||
slide: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
@@ -145,7 +151,7 @@ pub(crate) fn held_points(sk: &Sketch, hold: &Hold) -> Vec<bool> {
|
||||
|
||||
fn layout(sk: &Sketch, hold: &Hold) -> Layout {
|
||||
let mut n = 0;
|
||||
let pvar = held_points(sk, hold)
|
||||
let pvar: Vec<Option<usize>> = held_points(sk, hold)
|
||||
.iter()
|
||||
.map(|f| {
|
||||
if *f {
|
||||
@@ -169,7 +175,19 @@ fn layout(sk: &Sketch, hold: &Hold) -> Layout {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
Layout { pvar, rvar, n, points }
|
||||
let mut slide = vec![false; n];
|
||||
for c in &sk.constraints {
|
||||
if let ConstraintKind::PointOnCurve { p, .. } | ConstraintKind::Midpoint { p, .. } = c.kind
|
||||
&& let Some(Some(v)) = pvar.get(p)
|
||||
{
|
||||
for k in [*v, *v + 1] {
|
||||
if let Some(s) = slide.get_mut(k) {
|
||||
*s = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Layout { pvar, rvar, n, points, slide }
|
||||
}
|
||||
|
||||
/// Read-only view of the sketch with variable values substituted.
|
||||
@@ -618,7 +636,7 @@ fn measure(s: &State, k: &ConstraintKind) -> Option<f64> {
|
||||
/// What a dimension measures on the sketch as it stands (mm, radians for angles); `None` for a
|
||||
/// constraint that is not a dimension.
|
||||
pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option<f64> {
|
||||
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
|
||||
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
|
||||
measure(&State { sk, lay: &lay, x: &[] }, k).filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
@@ -954,13 +972,17 @@ fn lm_capped(
|
||||
}
|
||||
}
|
||||
let mut improved = false;
|
||||
// Damping floor: variables the residuals barely see must not take huge steps.
|
||||
let floor = (0..n).map(|i| a.get(i, i)).fold(0.0_f64, f64::max) * 1e-4;
|
||||
// Damping by distance moved, not by JᵀJ's diagonal (Marquardt): of the steps that fit the
|
||||
// residuals equally well it takes the shortest, so what the constraints leave free moves
|
||||
// as little as it can. (Scaled by the diagonal, a variable the residuals see weakly is
|
||||
// cheap to move: a point held on a line made the line's far end swing round instead of
|
||||
// the point sliding along it.) A point held on a curve is cheaper still, so it slides
|
||||
// rather than the curve's ends moving.
|
||||
let weight: Vec<f64> = vars.iter().map(|&v| if lay.slide.get(v).copied().unwrap_or(false) { SLIDE_WEIGHT } else { 1.0 }).collect();
|
||||
for _ in 0..30 {
|
||||
let mut m = a.clone();
|
||||
for i in 0..n {
|
||||
let d = a.get(i, i);
|
||||
m.add(i, i, lambda * (d.max(floor) + 1e-9) + 1e-12);
|
||||
for (i, w) in weight.iter().enumerate() {
|
||||
m.add(i, i, lambda * w + 1e-12);
|
||||
}
|
||||
let rhs: Vec<f64> = g.iter().map(|v| -v).collect();
|
||||
let Some(step) = lin_solve(m, rhs) else {
|
||||
@@ -2201,7 +2223,7 @@ fn solve_inner(
|
||||
None => true,
|
||||
};
|
||||
// Driven dimensions measure the solved geometry.
|
||||
let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
|
||||
let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
|
||||
let measured: Vec<Option<f64>> =
|
||||
sk.constraints.iter().map(|c| if c.driven { measure(&State { sk, lay: &fixed_lay, x: &[] }, &c.kind) } else { None }).collect();
|
||||
for (c, m) in sk.constraints.iter_mut().zip(measured) {
|
||||
@@ -2265,7 +2287,7 @@ mod continuation_unit_tests {
|
||||
let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap();
|
||||
let b = sk.add_point(Vec2::new(40.0, 0.0), None).unwrap();
|
||||
let c = sk.add_point(Vec2::new(20.0, 1e-6), None).unwrap();
|
||||
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
|
||||
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
|
||||
let turn = Orient::Turn([a, b, c]);
|
||||
let state =
|
||||
|sk: &Sketch| (orientation(&State { sk, lay: &lay, x: &[] }, turn), reference_orientation(&State { sk, lay: &lay, x: &[] }, turn));
|
||||
@@ -2285,7 +2307,7 @@ mod continuation_unit_tests {
|
||||
let q = sk.add_point(Vec2::new(10.0, 5.0), None).unwrap();
|
||||
let r = sk.add_point(Vec2::new(0.0, 10.0), None).unwrap();
|
||||
let l = sk.add_line(Vec2::new(-5.0, -5.0), Vec2::new(5.0, -5.0), None, None, None).unwrap();
|
||||
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
|
||||
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
|
||||
let sign = |sk: &Sketch, o: Orient| orientation(&State { sk, lay: &lay, x: &[] }, o);
|
||||
let checks = [Orient::SideX(p, q, 10.0), Orient::SideY(p, q, 5.0), Orient::OfLine(q, l, 10.0), Orient::Turn([p, q, r])];
|
||||
let before: Vec<_> = checks.iter().map(|o| sign(&sk, *o)).collect();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "solidcraft-3dx-client"
|
||||
description = "SolidCraft: a client of the 3Dconnexion driver on macOS (3DxWare), loaded at run time"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/macos.rs can allow
|
||||
# it: loading a framework and calling through its function pointers has no safe API. Keep the rest
|
||||
# in sync with [workspace.lints].
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
neg_cmp_op_on_partial_ord = "allow"
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Prints what the 3Dconnexion driver reports: `cargo run -p solidcraft-3dx-client --example dump`.
|
||||
//! The driver sends nothing to a program that is not in an app bundle, so only `DeviceAdded` shows here;
|
||||
//! use SolidCraft's own `.app` to see the cap move.
|
||||
|
||||
fn main() {
|
||||
if let Err(error) = solidcraft_3dx_client::start("SolidCraft dump", |event| println!("{event:?}")) {
|
||||
eprintln!("{error}");
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! A client of the 3Dconnexion driver (3DxWare) on macOS.
|
||||
//!
|
||||
//! The driver's helper app opens the device exclusively while it runs, so reading the HID device
|
||||
//! directly fails then. The driver offers its own client API instead, in
|
||||
//! `3DconnexionClient.framework`: an app registers with the driver and gets the cap's six axes
|
||||
//! (after the user's 3DxWare settings) and the buttons as messages on a run loop.
|
||||
//!
|
||||
//! The framework is loaded with `dlopen` when [`start`] is called, so nothing links against it and
|
||||
//! the app runs without the driver installed ([`Error::NotInstalled`]). The framework is never
|
||||
//! bundled or redistributed; its declarations here are our own. On other platforms [`start`]
|
||||
//! returns [`Error::Unsupported`].
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
||||
/// What the driver reports.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Event {
|
||||
/// A 3D mouse was plugged in or the driver connected to one.
|
||||
DeviceAdded,
|
||||
/// The 3D mouse was unplugged or the driver went away.
|
||||
DeviceRemoved,
|
||||
/// The cap moved: push left/right, forward/back, lift/press, then tilt forward/back,
|
||||
/// tilt left/right and twist, each about -350..350 as the HID device reports them.
|
||||
Axes { translation: [i16; 3], rotation: [i16; 3] },
|
||||
/// The buttons that are down, one bit each.
|
||||
Buttons(u32),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// Not macOS.
|
||||
Unsupported,
|
||||
/// The driver's framework is not installed (or not loadable).
|
||||
NotInstalled,
|
||||
/// The framework lacks a function this client needs (another driver version).
|
||||
MissingSymbol(&'static str),
|
||||
/// The driver refused the handlers (its `OSErr`).
|
||||
Handlers(i16),
|
||||
/// The driver refused the client registration.
|
||||
Register,
|
||||
/// [`start`] was called before.
|
||||
AlreadyStarted,
|
||||
/// The client's thread could not be started.
|
||||
Thread(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::Unsupported => write!(f, "the 3Dconnexion driver client only exists on macOS"),
|
||||
Error::NotInstalled => write!(f, "the 3Dconnexion driver is not installed"),
|
||||
Error::MissingSymbol(name) => write!(f, "the 3Dconnexion driver lacks {name}"),
|
||||
Error::Handlers(code) => write!(f, "the 3Dconnexion driver refused the handlers (error {code})"),
|
||||
Error::Register => write!(f, "the 3Dconnexion driver refused the client"),
|
||||
Error::AlreadyStarted => write!(f, "the 3Dconnexion client is already started"),
|
||||
Error::Thread(reason) => write!(f, "the 3Dconnexion client thread did not start: {reason}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
/// Connects to the driver and calls `sink` (on the client's own thread) for everything it reports.
|
||||
/// `name` is how the driver lists this app. Only one client per process; the connection lasts
|
||||
/// until the process ends.
|
||||
pub fn start(name: &str, sink: impl Fn(Event) + Send + Sync + 'static) -> Result<(), Error> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::start(name, Box::new(sink))
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = (name, sink);
|
||||
Err(Error::Unsupported)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! The driver's client API, called through function pointers from `dlopen`.
|
||||
//!
|
||||
//! No safe API exists for this, hence the `unsafe_code` allowance for the whole module. The
|
||||
//! unsafe parts are small: the C declarations, the lookups and the two callbacks, which read the
|
||||
//! message the driver hands them.
|
||||
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::ffi::{CString, c_char, c_int, c_void};
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
|
||||
use crate::{Error, Event};
|
||||
|
||||
const FRAMEWORK: &str = "/Library/Frameworks/3DconnexionClient.framework/3DconnexionClient";
|
||||
const RTLD_NOW: c_int = 2;
|
||||
|
||||
/// `'****'`: any application signature.
|
||||
const WILDCARD_SIGNATURE: u32 = 0x2A2A_2A2A;
|
||||
/// The driver gives this client the cap, instead of sharing it with other clients.
|
||||
const MODE_TAKE_OVER: u16 = 1;
|
||||
/// Every axis and every button.
|
||||
const MASK_ALL: u32 = 0x3FFF;
|
||||
/// `'3dSR'`: the message that carries a device state.
|
||||
const MSG_DEVICE_STATE: u32 = 0x3364_5352;
|
||||
const CMD_BUTTONS: u16 = 2;
|
||||
const CMD_AXES: u16 = 3;
|
||||
|
||||
/// The device state the driver passes, read field by field from these byte offsets (the struct is
|
||||
/// packed to two bytes) so that no reference to driver memory is ever formed.
|
||||
const STATE_BYTES: usize = 48;
|
||||
const OFFSET_CLIENT: usize = 2;
|
||||
const OFFSET_COMMAND: usize = 4;
|
||||
const OFFSET_AXES: usize = 30;
|
||||
const OFFSET_BUTTONS: usize = 44;
|
||||
|
||||
unsafe extern "C" {
|
||||
fn dlopen(path: *const c_char, mode: c_int) -> *mut c_void;
|
||||
fn dlsym(handle: *mut c_void, name: *const c_char) -> *mut c_void;
|
||||
}
|
||||
|
||||
#[link(name = "CoreFoundation", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
static kCFRunLoopDefaultMode: *const c_void;
|
||||
fn CFRunLoopRunInMode(mode: *const c_void, seconds: f64, return_after_source_handled: u8) -> i32;
|
||||
}
|
||||
|
||||
type MessageHandler = extern "C" fn(u32, u32, *mut c_void);
|
||||
type DeviceHandler = extern "C" fn(u32);
|
||||
type InstallHandlers = unsafe extern "C" fn(Option<MessageHandler>, Option<DeviceHandler>, Option<DeviceHandler>) -> i16;
|
||||
type RegisterClient = unsafe extern "C" fn(u32, *const u8, u16, u32) -> u16;
|
||||
type SetMask = unsafe extern "C" fn(u16, u32);
|
||||
|
||||
type Sink = Box<dyn Fn(Event) + Send + Sync>;
|
||||
|
||||
static SINK: OnceLock<Sink> = OnceLock::new();
|
||||
/// The id the driver gave this client: it broadcasts every message to every client.
|
||||
static CLIENT: AtomicU16 = AtomicU16::new(0);
|
||||
|
||||
fn symbol(handle: *mut c_void, name: &'static str) -> Result<*mut c_void, Error> {
|
||||
let c_name = CString::new(name).map_err(|_| Error::MissingSymbol(name))?;
|
||||
// SAFETY: `handle` came from a successful dlopen and `c_name` is a NUL-terminated string.
|
||||
let found = unsafe { dlsym(handle, c_name.as_ptr()) };
|
||||
if found.is_null() { Err(Error::MissingSymbol(name)) } else { Ok(found) }
|
||||
}
|
||||
|
||||
fn emit(event: Event) {
|
||||
if let Some(sink) = SINK.get() {
|
||||
// A panic must not unwind into the driver's C code.
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| sink(event)));
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn on_added(_connection: u32) {
|
||||
emit(Event::DeviceAdded);
|
||||
}
|
||||
|
||||
extern "C" fn on_removed(_connection: u32) {
|
||||
emit(Event::DeviceRemoved);
|
||||
}
|
||||
|
||||
extern "C" fn on_message(_connection: u32, kind: u32, argument: *mut c_void) {
|
||||
if kind != MSG_DEVICE_STATE || argument.is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: for this message type the driver passes a device state, which is `STATE_BYTES` long
|
||||
// and valid for the duration of the call.
|
||||
let state = unsafe { std::slice::from_raw_parts(argument.cast::<u8>().cast_const(), STATE_BYTES) };
|
||||
if let Some(event) = decode(state, CLIENT.load(Ordering::Relaxed)) {
|
||||
emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn u16_at(state: &[u8], offset: usize) -> Option<u16> {
|
||||
Some(u16::from_ne_bytes(state.get(offset..offset + 2)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn i16_at(state: &[u8], offset: usize) -> Option<i16> {
|
||||
Some(i16::from_ne_bytes(state.get(offset..offset + 2)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// The event a device state stands for, if it is meant for `client`.
|
||||
fn decode(state: &[u8], client: u16) -> Option<Event> {
|
||||
if u16_at(state, OFFSET_CLIENT)? != client {
|
||||
return None;
|
||||
}
|
||||
match u16_at(state, OFFSET_COMMAND)? {
|
||||
CMD_AXES => {
|
||||
let axis = |i: usize| i16_at(state, OFFSET_AXES + 2 * i);
|
||||
Some(Event::Axes { translation: [axis(0)?, axis(1)?, axis(2)?], rotation: [axis(3)?, axis(4)?, axis(5)?] })
|
||||
}
|
||||
CMD_BUTTONS => Some(Event::Buttons(u32::from_ne_bytes(state.get(OFFSET_BUTTONS..OFFSET_BUTTONS + 4)?.try_into().ok()?))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the framework, installs the handlers and registers the client. Runs on the client thread,
|
||||
/// whose run loop the driver then sends its messages to.
|
||||
fn connect(name: &str) -> Result<(), Error> {
|
||||
let path = CString::new(FRAMEWORK).map_err(|_| Error::NotInstalled)?;
|
||||
// SAFETY: `path` is a NUL-terminated string.
|
||||
let handle = unsafe { dlopen(path.as_ptr(), RTLD_NOW) };
|
||||
if handle.is_null() {
|
||||
return Err(Error::NotInstalled);
|
||||
}
|
||||
let install = symbol(handle, "InstallConnexionHandlers")?;
|
||||
let register = symbol(handle, "RegisterConnexionClient")?;
|
||||
// SAFETY: the framework declares these functions with exactly these signatures.
|
||||
let (install, register) =
|
||||
unsafe { (std::mem::transmute::<*mut c_void, InstallHandlers>(install), std::mem::transmute::<*mut c_void, RegisterClient>(register)) };
|
||||
// SAFETY: the handlers are `extern "C"` functions that live for the whole process.
|
||||
let code = unsafe { install(Some(on_message), Some(on_added), Some(on_removed)) };
|
||||
if code != 0 {
|
||||
return Err(Error::Handlers(code));
|
||||
}
|
||||
// The name is a Pascal string: a length byte, then the characters.
|
||||
let mut pascal = vec![0_u8];
|
||||
pascal.extend(name.bytes().take(255));
|
||||
pascal[0] = u8::try_from(pascal.len() - 1).unwrap_or(0);
|
||||
pascal.push(0);
|
||||
// SAFETY: `pascal` outlives the call, which copies it.
|
||||
let id = unsafe { register(WILDCARD_SIGNATURE, pascal.as_ptr(), MODE_TAKE_OVER, MASK_ALL) };
|
||||
if id == 0 {
|
||||
return Err(Error::Register);
|
||||
}
|
||||
CLIENT.store(id, Ordering::Relaxed);
|
||||
// Newer drivers want the masks again once the client has an id. Both are optional.
|
||||
for (name, mask) in [("SetConnexionClientMask", MASK_ALL), ("SetConnexionClientButtonMask", u32::MAX)] {
|
||||
if let Ok(set) = symbol(handle, name) {
|
||||
// SAFETY: the framework declares these functions with this signature.
|
||||
let set = unsafe { std::mem::transmute::<*mut c_void, SetMask>(set) };
|
||||
// SAFETY: `id` is the id the driver gave this client.
|
||||
unsafe { set(id, mask) };
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn start(name: &str, sink: Sink) -> Result<(), Error> {
|
||||
SINK.set(sink).map_err(|_| Error::AlreadyStarted)?;
|
||||
let name = name.to_owned();
|
||||
let (ready, started) = std::sync::mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
.name("3dx-client".into())
|
||||
.spawn(move || {
|
||||
let result = connect(&name);
|
||||
let connected = result.is_ok();
|
||||
let _ = ready.send(result);
|
||||
if !connected {
|
||||
return;
|
||||
}
|
||||
// The driver's messages arrive on this thread's run loop. Waking up now and then keeps
|
||||
// the loop alive in case it had nothing to wait for yet.
|
||||
loop {
|
||||
// SAFETY: the mode is CoreFoundation's own constant.
|
||||
unsafe { CFRunLoopRunInMode(kCFRunLoopDefaultMode, 1.0, 0) };
|
||||
}
|
||||
})
|
||||
.map_err(|error| Error::Thread(error.to_string()))?;
|
||||
started.recv().map_err(|error| Error::Thread(error.to_string()))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A device state as the driver sent it for a light tilt forward (captured from a SpaceMouse
|
||||
/// Wireless): command 3 (axes) with the fourth axis, tilt forward/back, at -3.
|
||||
fn tilt_state(client: u16) -> [u8; STATE_BYTES] {
|
||||
let mut state = [0_u8; STATE_BYTES];
|
||||
state[OFFSET_CLIENT..OFFSET_CLIENT + 2].copy_from_slice(&client.to_ne_bytes());
|
||||
state[OFFSET_COMMAND..OFFSET_COMMAND + 2].copy_from_slice(&CMD_AXES.to_ne_bytes());
|
||||
state[OFFSET_AXES + 6..OFFSET_AXES + 8].copy_from_slice(&(-3_i16).to_ne_bytes());
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_axes_state_for_this_client_becomes_axes() {
|
||||
assert_eq!(decode(&tilt_state(4106), 4106), Some(Event::Axes { translation: [0; 3], rotation: [-3, 0, 0] }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_for_another_client_is_ignored() {
|
||||
assert_eq!(decode(&tilt_state(4106), 4107), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_button_state_carries_the_mask() {
|
||||
let mut state = tilt_state(7);
|
||||
state[OFFSET_COMMAND..OFFSET_COMMAND + 2].copy_from_slice(&CMD_BUTTONS.to_ne_bytes());
|
||||
state[OFFSET_BUTTONS..OFFSET_BUTTONS + 4].copy_from_slice(&5_u32.to_ne_bytes());
|
||||
assert_eq!(decode(&state, 7), Some(Event::Buttons(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_message_is_ignored() {
|
||||
assert_eq!(decode(&[0; 10], 0), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! The Boundary Surface dialog (#763): a loop of sketch curves becomes the loop patch, edges and
|
||||
//! curves across become a network with its continuity and operation, curves that meet end to end
|
||||
//! are one chain, and Edit Feature brings a network back as it was.
|
||||
use crate::dialogs::Kind;
|
||||
use crate::dialogs_surface::Surf;
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Map, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::{FeatureKind, Operation, kernel::Continuity};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
fn ok(h: &mut Harness) {
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
// An edit is applied off the UI thread: give it the frames it takes.
|
||||
for _ in 0..200 {
|
||||
if h.app.dialog.is_none() {
|
||||
break;
|
||||
}
|
||||
h.frames(1);
|
||||
}
|
||||
assert!(h.app.dialog.is_none(), "{:?} {:?} {:?}", h.app.preview.error, h.app.dialog.as_ref().map(|d| (&d.error, &d.inputs)), h.app.session.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_of_sketch_curves_is_the_loop_patch() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Loop"})).unwrap();
|
||||
for (id, a, b) in [("s1", [0, 0], [10, 0]), ("s2", [10, 0], [10, 10]), ("s3", [10, 10], [0, 10]), ("s4", [0, 10], [0, 0])] {
|
||||
h.app.run("sketch.line", json!({"points":[a, b],"ids":[id]})).unwrap();
|
||||
}
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.step(&json!({"call":"ui.start","params":{"command":"surface.boundary"}})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.widget("Cross curves").is_some() && h.widget("Continuity").is_some() && h.widget("Operation").is_some());
|
||||
let d = h.app.dialog.as_mut().unwrap();
|
||||
d.inputs[0].items = ["s1", "s2", "s3", "s4"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
|
||||
ok(&mut h);
|
||||
let f = h.app.session.doc.features.last().unwrap();
|
||||
let FeatureKind::SurfaceBoundary { curves, orders, .. } = &f.kind else { panic!("{:?}", f.kind) };
|
||||
assert_eq!(curves, &["s1", "s2", "s3", "s4"]);
|
||||
assert!(orders.iter().all(|o| *o == Continuity::G1), "{orders:?}");
|
||||
assert_eq!(h.app.session.world_state().bodies.len(), 1);
|
||||
}
|
||||
|
||||
/// A quarter cylinder sheet (its top edge open at y = 10, z = 0) and a 3D line above it.
|
||||
fn cylinder_and_line(h: &mut Harness) -> String {
|
||||
h.app.run("sketch.create", json!({"plane":"YZ","name":"Arc"})).unwrap();
|
||||
let c = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
|
||||
let r = h.app.run("sketch.arc.three_point", json!({"start":[0,-10],"end":[10,0],"through":[c,-c]})).unwrap();
|
||||
let arc = r["created"]["curves"][0].as_str().unwrap().to_string();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("surface.extrude", json!({"sketch":"Arc","curves":[arc],"distance":10})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Top"})).unwrap();
|
||||
h.app.run("sketch.options", json!({"sketch_3d": true})).unwrap();
|
||||
let r = h.app.run("sketch.line3d", json!({"points":[[0,3,12],[10,3,12]]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
r["curves"][0].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_and_curves_make_a_network_and_come_back_in_an_edit() {
|
||||
let mut h = Harness::new();
|
||||
let top = cylinder_and_line(&mut h);
|
||||
let rim = crate::dialogs::edge_sel(&h.app.session, Vec3::new(5.0, 10.0, 0.0)).unwrap();
|
||||
let Sel::Edge { body, .. } = &rim else { panic!("{rim:?}") };
|
||||
let body = body.clone();
|
||||
h.step(&json!({"call":"ui.start","params":{"command":"surface.boundary"}})).unwrap();
|
||||
h.frames(3);
|
||||
let d = h.app.dialog.as_mut().unwrap();
|
||||
d.inputs[0].items = vec![rim.clone(), Sel::SketchCurve { id: top.clone() }];
|
||||
let Kind::Surface(Surf::Boundary { continuity, join }) = &mut d.kind else { panic!("{:?}", d.kind) };
|
||||
*continuity = 2;
|
||||
*join = true;
|
||||
ok(&mut h);
|
||||
let name = h.app.session.doc.features.last().unwrap().name.clone();
|
||||
let network = |h: &Harness| match &h.app.session.doc.find_feature(&name).unwrap().kind {
|
||||
FeatureKind::SurfaceNetwork { u, v, operation } => (u.clone(), v.clone(), *operation),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let (u, v, operation) = network(&h);
|
||||
assert_eq!((u.len(), v.len(), operation), (2, 0, Operation::Join));
|
||||
assert_eq!(u[0].body.as_ref().map(|b| b.to_string()), Some(body.clone()));
|
||||
assert_eq!(u[0].continuity, Continuity::G2);
|
||||
assert_eq!(u[1].curves, vec![top.clone()]);
|
||||
// Joined into the sheet: one body, two faces.
|
||||
let st = h.app.session.world_state();
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
assert_eq!(st.body(&body).map(|b| b.body.face_count()), Some(2));
|
||||
drop(st);
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":name}})).unwrap();
|
||||
h.frames(8);
|
||||
let d = h.app.dialog.as_ref().unwrap();
|
||||
assert!(matches!(d.kind, Kind::Surface(Surf::Boundary { continuity: 2, join: true })), "{:?}", d.kind);
|
||||
assert_eq!(d.inputs[0].items.len(), 2, "{:?}", d.inputs[0].items);
|
||||
ok(&mut h);
|
||||
let (u2, v2, op2) = network(&h);
|
||||
assert_eq!((u2.len(), v2.len(), op2), (2, 0, Operation::Join));
|
||||
assert_eq!((u2[0].continuity, &u2[1].curves), (Continuity::G2, &vec![top]));
|
||||
}
|
||||
|
||||
/// Picks that meet end to end in one sketch are one curve of the network; a pick elsewhere is
|
||||
/// another.
|
||||
#[test]
|
||||
fn curves_that_meet_are_one_chain() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Net"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,0],[5,0]],"ids":["a1"]})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[5,0],[10,0]],"ids":["a2"]})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,8],[10,8]],"ids":["b"]})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,0],[0,8]],"ids":["c"]})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[10,0],[10,8]],"ids":["d"]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let sketch = h.app.session.doc.find_feature("Net").unwrap().id;
|
||||
let k = Surf::Boundary { continuity: 0, join: false };
|
||||
let (_, mut inputs) = crate::dialogs_surface::start(&h.app, "surface.boundary").unwrap();
|
||||
inputs[0].items = ["a2", "b", "a1"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
|
||||
inputs[1].items = ["c", "d"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
|
||||
let cmds = crate::dialogs_surface::commands(&h.app, &k, &inputs, &Map::new()).unwrap();
|
||||
let (id, p) = &cmds[0];
|
||||
assert_eq!(id, "surface.boundary");
|
||||
assert_eq!(p["u"], json!([{"sketch": sketch, "curves": ["a2", "a1"]}, {"sketch": sketch, "curves": ["b"]}]), "{p}");
|
||||
assert_eq!(p["v"], json!([{"sketch": sketch, "curves": ["c"]}, {"sketch": sketch, "curves": ["d"]}]), "{p}");
|
||||
// And it builds: the chain a1–a2 is one boundary of the patch.
|
||||
h.app.run("surface.boundary", p.clone()).unwrap();
|
||||
assert_eq!(h.app.session.world_state().bodies.len(), 1);
|
||||
}
|
||||
@@ -151,6 +151,8 @@ pub enum Kind {
|
||||
thickness: String,
|
||||
depth: String,
|
||||
flip: bool,
|
||||
/// Grow along the sketch normal rather than across the plane.
|
||||
normal: bool,
|
||||
},
|
||||
/// Raise or sink profiles of a sketch on a face.
|
||||
Emboss {
|
||||
@@ -491,7 +493,7 @@ impl Dialog {
|
||||
Dialog::new(Kind::Mirror, vec![SelInput::new("Objects", BODIES, true), SelInput::new("Mirror Plane", PLANES | PLANAR_FACES, false)])
|
||||
}
|
||||
"solid.rib" | "solid.web" => Dialog::new(
|
||||
Kind::Rib { web: id == "solid.web", thickness: "2 mm".into(), depth: String::new(), flip: false },
|
||||
Kind::Rib { web: id == "solid.web", thickness: "2 mm".into(), depth: String::new(), flip: false, normal: false },
|
||||
vec![SelInput::new("Curves", CURVES, true)],
|
||||
),
|
||||
"solid.emboss" => Dialog::new(
|
||||
@@ -2701,7 +2703,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Split | Kind::ReplaceFace | Kind::Remove | Kind::Midplane => {}
|
||||
Kind::Rib { thickness, depth, flip, .. } => {
|
||||
Kind::Rib { thickness, depth, flip, normal, .. } => {
|
||||
row_label(ui, "Direction");
|
||||
let mut i = usize::from(*normal);
|
||||
combo(ui, "rib_direction", &["In plane", "Normal to sketch"], &mut i);
|
||||
*normal = i == 1;
|
||||
ui.end_row();
|
||||
row_label(ui, "Thickness");
|
||||
enter |= field(ui, thickness);
|
||||
ui.end_row();
|
||||
@@ -3753,7 +3760,36 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
if !sketch.is_null() {
|
||||
out.push(("solid.extrude".to_string(), with(json!({"sketch": sketch, "profiles": idx}))));
|
||||
}
|
||||
// Each planar body face extrudes on its own.
|
||||
// Coplanar faces of one body share one sketch and Boolean. Mixed selections
|
||||
// retain their original order and explicit face-body scope.
|
||||
let state = s.world_state();
|
||||
let mut group: Option<(String, solidcraft_engine::geom::Plane, Vec<Value>)> = None;
|
||||
let mut coplanar = true;
|
||||
for pick in sels(d, 0) {
|
||||
let Sel::Face { body, index, point } = pick else { continue };
|
||||
let Some(source) = state.body(body) else { return Err("selected body disappeared".into()) };
|
||||
let Some(plane) = source.body.planar_face(*index) else {
|
||||
coplanar = false;
|
||||
break;
|
||||
};
|
||||
let tolerance = (source.body.size() * 1e-9).max(1e-8);
|
||||
if let Some((name, base, points)) = &mut group {
|
||||
if name != body || base.normal().dot(plane.normal()) <= 1.0 - 1e-9 || base.height(plane.origin).abs() > tolerance {
|
||||
coplanar = false;
|
||||
break;
|
||||
}
|
||||
points.push(pt(*point));
|
||||
} else {
|
||||
group = Some((body.clone(), plane, vec![pt(*point)]));
|
||||
}
|
||||
}
|
||||
if coplanar
|
||||
&& let Some((body, _, points)) = group
|
||||
&& points.len() > 1
|
||||
{
|
||||
out.push(("solid.extrude".to_string(), with(json!({"faces": points, "face_body": body}))));
|
||||
return Ok(out);
|
||||
}
|
||||
for pick in sels(d, 0) {
|
||||
if let Sel::Face { body, point, .. } = pick {
|
||||
out.push(("solid.extrude".to_string(), with(json!({"face": pt(*point), "face_body": body}))));
|
||||
@@ -3977,11 +4013,14 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
};
|
||||
("construct.plane.at_angle", json!({"base": base, "axis": axis, "angle": angle}))
|
||||
}
|
||||
Kind::Rib { web, thickness, depth, flip } => {
|
||||
Kind::Rib { web, thickness, depth, flip, normal } => {
|
||||
need(0, "open sketch curves")?;
|
||||
let curves = curve_ids(d, 0);
|
||||
let sketch = curves_sketch(app, &curves, None).ok_or("the curves must be in one sketch")?;
|
||||
let mut p = json!({"sketch": sketch, "curves": curves, "thickness": thickness, "flip": flip});
|
||||
if *normal {
|
||||
p["direction"] = json!("normal");
|
||||
}
|
||||
if !depth.trim().is_empty() {
|
||||
p["depth"] = json!(depth);
|
||||
}
|
||||
@@ -5204,13 +5243,14 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Rib { curves, thickness, depth, flip, web, .. } => {
|
||||
FeatureKind::Rib { curves, thickness, depth, flip, web, normal, .. } => {
|
||||
let mut d = start(if *web { "solid.web" } else { "solid.rib" })?;
|
||||
d.kind = Kind::Rib {
|
||||
web: *web,
|
||||
thickness: thickness.to_string(),
|
||||
depth: depth.as_deref().map(str::to_string).unwrap_or_default(),
|
||||
flip: *flip,
|
||||
normal: *normal,
|
||||
};
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
inp.items = curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Revolve, Sweep and
|
||||
//! Loft, Offset and Ruled Surface, Patch, Stitch, Thicken, Extend, Trim, Untrim and Split Face. Each has a live preview and Edit Feature.
|
||||
//! Loft, Offset and Ruled Surface, Boundary Surface, Patch, Stitch, Thicken, Extend, Trim, Untrim
|
||||
//! and Split Face. Each has a live preview and Edit Feature.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
@@ -27,6 +28,13 @@ pub enum Surf {
|
||||
},
|
||||
Sweep(crate::dialogs_sweep::SweepOpts),
|
||||
Loft,
|
||||
/// `continuity`: an index into [`PATCH_CONTINUITY`] (for a loop of sketch curves, how much of
|
||||
/// the sketch's plane each side repeats; for picked edges, how the surface meets the faces
|
||||
/// beside them); `join`: sew the surface into the body of its edges.
|
||||
Boundary {
|
||||
continuity: usize,
|
||||
join: bool,
|
||||
},
|
||||
/// `continuity`: an index into [`PATCH_CONTINUITY`] (how a patch on edges meets the faces
|
||||
/// beside its loop).
|
||||
Patch {
|
||||
@@ -74,6 +82,7 @@ impl Surf {
|
||||
Surf::Revolve { .. } => "SURFACE REVOLVE",
|
||||
Surf::Sweep(_) => "SURFACE SWEEP",
|
||||
Surf::Loft => "SURFACE LOFT",
|
||||
Surf::Boundary { .. } => "BOUNDARY SURFACE",
|
||||
Surf::Patch { .. } => "PATCH",
|
||||
Surf::Unstitch => "UNSTITCH",
|
||||
Surf::ReverseNormal => "REVERSE NORMAL",
|
||||
@@ -104,6 +113,9 @@ impl Surf {
|
||||
/// How a patch on edges meets the faces beside its loop (labels, and the `continuity` parameter).
|
||||
const PATCH_CONTINUITY: [(&str, &str); 3] = [("Position (G0)", "g0"), ("Tangent (G1)", "g1"), ("Curvature (G2)", "g2")];
|
||||
|
||||
/// What a boundary surface becomes: a body of its own, or part of the body of its edges.
|
||||
const BOUNDARY_OPS: [&str; 2] = ["New Body", "Join"];
|
||||
|
||||
/// Which holes Untrim takes away: every one, the picked ones, or none (the outer boundary only).
|
||||
const UNTRIM_HOLES: [&str; 3] = ["All", "Picked", "None"];
|
||||
|
||||
@@ -128,6 +140,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
|
||||
]
|
||||
}
|
||||
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
|
||||
Surf::Boundary { .. } => vec![SelInput::new("Curves", CURVES | EDGES, true), SelInput::new("Cross curves", CURVES | EDGES, true)],
|
||||
Surf::Patch { .. } => vec![SelInput::new("Boundary", PROFILES | EDGES, true), SelInput::new("Pass through", VERTICES | FACES, true)],
|
||||
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
|
||||
Surf::ReverseNormal => vec![SelInput::new("Surface, or faces to turn over", BODIES | FACES, true)],
|
||||
@@ -155,6 +168,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
|
||||
"surface.sweep" => Surf::Sweep(crate::dialogs_sweep::SweepOpts::default()),
|
||||
"surface.loft" => Surf::Loft,
|
||||
"surface.boundary" => Surf::Boundary { continuity: 1, join: false },
|
||||
"surface.patch" => Surf::Patch { continuity: 0 },
|
||||
"surface.unstitch" => Surf::Unstitch,
|
||||
"surface.reverse_normal" => Surf::ReverseNormal,
|
||||
@@ -264,6 +278,16 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
|
||||
combo(ui, "pa_cont", &PATCH_CONTINUITY.map(|(label, _)| label), continuity);
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Boundary { continuity, join } => {
|
||||
row_label(ui, "Continuity");
|
||||
combo(ui, "bo_cont", &PATCH_CONTINUITY.map(|(label, _)| label), continuity);
|
||||
ui.end_row();
|
||||
row_label(ui, "Operation");
|
||||
let mut op = usize::from(*join);
|
||||
combo(ui, "bo_op", &BOUNDARY_OPS, &mut op);
|
||||
*join = op == 1;
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Sweep(opts) => enter |= crate::dialogs_sweep::rows(ui, opts, "ss"),
|
||||
Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {}
|
||||
}
|
||||
@@ -342,6 +366,98 @@ fn tool(app: &SolveApp, sel: Option<&Sel>) -> Result<(&'static str, Value), Stri
|
||||
}
|
||||
}
|
||||
|
||||
/// One curve of a boundary surface as the dialog picks it: sketch curves of one sketch, or edges
|
||||
/// of one body, that meet end to end.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Chain {
|
||||
Sketch { sketch: u64, ids: Vec<String> },
|
||||
Edges { body: String, points: Vec<Vec3> },
|
||||
}
|
||||
|
||||
/// The picks grouped into curves: picks of one sketch (or one body) whose ends meet run on into
|
||||
/// one chain, in the order they were first picked; anything else is a curve of its own.
|
||||
fn chains(app: &SolveApp, picks: &[Sel]) -> Result<Vec<Chain>, String> {
|
||||
let st = app.session.world_state();
|
||||
// Each pick: what it belongs to (a sketch or a body), its ends, and how it joins a chain.
|
||||
let mut parts: Vec<(Chain, [Vec3; 2])> = Vec::with_capacity(picks.len());
|
||||
for x in picks {
|
||||
match x {
|
||||
Sel::SketchCurve { id } => {
|
||||
let sketch = crate::dialogs::curves_sketch(app, std::slice::from_ref(id), None).ok_or("a picked curve is no longer in a sketch")?;
|
||||
let ss = st.sketch(sketch).ok_or("a picked curve's sketch has not been solved")?;
|
||||
let ends = if let Some(ci) = ss.sketch.curve_index(id) {
|
||||
let segs = ss.sketch.segs(ci);
|
||||
match (segs.first(), segs.last()) {
|
||||
(Some(a), Some(b)) => [ss.plane.to_world(a.start()), ss.plane.to_world(b.end())],
|
||||
_ => return Err(format!("curve `{id}` has no geometry")),
|
||||
}
|
||||
} else {
|
||||
let w = ss.sketch.wire_index(id).and_then(|wi| ss.sketch.wires.get(wi)).ok_or("a picked curve is no longer in a sketch")?;
|
||||
match (w.pts.first(), w.pts.last()) {
|
||||
(Some(a), Some(b)) => [*a, *b],
|
||||
_ => return Err(format!("curve `{id}` has no geometry")),
|
||||
}
|
||||
};
|
||||
parts.push((Chain::Sketch { sketch, ids: vec![id.clone()] }, ends));
|
||||
}
|
||||
Sel::Edge { body, index, point } => {
|
||||
let b = st.body(body).ok_or("a picked edge's body is gone")?;
|
||||
let m = b.mesh();
|
||||
let e = m.edges.get(*index).ok_or("a picked edge is gone")?;
|
||||
let (Some(a), Some(z)) = (e.first(), e.last()) else { return Err("a picked edge has no points".into()) };
|
||||
parts.push((Chain::Edges { body: body.clone(), points: vec![*point] }, [*a, *z]));
|
||||
}
|
||||
_ => return Err("pick sketch curves or edges".into()),
|
||||
}
|
||||
}
|
||||
let same_source = |a: &Chain, b: &Chain| match (a, b) {
|
||||
(Chain::Sketch { sketch: x, .. }, Chain::Sketch { sketch: y, .. }) => x == y,
|
||||
(Chain::Edges { body: x, .. }, Chain::Edges { body: y, .. }) => x == y,
|
||||
_ => false,
|
||||
};
|
||||
let meet = |a: &[Vec3; 2], b: &[Vec3; 2]| a.iter().any(|p| b.iter().any(|q| p.dist(*q) <= 1e-6 * (1.0 + p.len())));
|
||||
// Union the picks that meet, keeping the first pick of each chain in front.
|
||||
let mut group: Vec<usize> = (0..parts.len()).collect();
|
||||
fn root(group: &[usize], mut i: usize) -> usize {
|
||||
while let Some(&g) = group.get(i) {
|
||||
if g == i {
|
||||
break;
|
||||
}
|
||||
i = g;
|
||||
}
|
||||
i
|
||||
}
|
||||
for i in 0..parts.len() {
|
||||
for j in 0..i {
|
||||
let (Some((ci, ei)), Some((cj, ej))) = (parts.get(i), parts.get(j)) else { continue };
|
||||
if same_source(ci, cj) && meet(ei, ej) {
|
||||
let (ri, rj) = (root(&group, i), root(&group, j));
|
||||
if let Some(g) = group.get_mut(ri.max(rj)) {
|
||||
*g = ri.min(rj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<(usize, Chain)> = Vec::new();
|
||||
for (i, (part, _)) in parts.into_iter().enumerate() {
|
||||
let r = root(&group, i);
|
||||
match out.iter_mut().find(|(k, _)| *k == r) {
|
||||
Some((_, Chain::Sketch { ids, .. })) => {
|
||||
if let Chain::Sketch { ids: more, .. } = part {
|
||||
ids.extend(more);
|
||||
}
|
||||
}
|
||||
Some((_, Chain::Edges { points, .. })) => {
|
||||
if let Chain::Edges { points: more, .. } = part {
|
||||
points.extend(more);
|
||||
}
|
||||
}
|
||||
None => out.push((r, part)),
|
||||
}
|
||||
}
|
||||
Ok(out.into_iter().map(|(_, c)| c).collect())
|
||||
}
|
||||
|
||||
/// The command a surface dialog makes.
|
||||
pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
|
||||
let need = |i: usize, what: &str| if items(inputs, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) };
|
||||
@@ -407,6 +523,48 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
.collect::<Result<_, String>>()?;
|
||||
("surface.loft", json!({ "sections": sections }))
|
||||
}
|
||||
Surf::Boundary { continuity, join } => {
|
||||
need(0, "the curves (sketch curves or edges)")?;
|
||||
let word = PATCH_CONTINUITY.get(*continuity).map_or("g0", |(_, w)| *w);
|
||||
let across = items(inputs, 1);
|
||||
let picks = items(inputs, 0);
|
||||
let sketch_ids: Vec<String> =
|
||||
picks.iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
|
||||
// Two to four sketch curves of one sketch and nothing across: the loop patch.
|
||||
let looped = across.is_empty() && !*join && sketch_ids.len() == picks.len() && (2..=4).contains(&picks.len());
|
||||
match looped.then(|| crate::dialogs::curves_sketch(app, &sketch_ids, None)).flatten() {
|
||||
Some(sk) => {
|
||||
let mut p = json!({"sketch": sk, "curves": sketch_ids});
|
||||
// Orders that differ from side to side (kept from the feature) stand as they are.
|
||||
if !extra.contains_key("continuity") {
|
||||
p["continuity"] = json!(word);
|
||||
}
|
||||
("surface.boundary", p)
|
||||
}
|
||||
None => {
|
||||
let curve = |c: &Chain| match c {
|
||||
Chain::Sketch { sketch, ids } => json!({"sketch": sketch, "curves": ids}),
|
||||
Chain::Edges { body, points } => {
|
||||
let mut v = json!({"body": body, "edges": points.iter().map(|p| pt(*p)).collect::<Vec<_>>()});
|
||||
if *continuity > 0 {
|
||||
v["continuity"] = json!(word);
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
let u: Vec<Value> = chains(app, picks)?.iter().map(curve).collect();
|
||||
let v: Vec<Value> = chains(app, across)?.iter().map(curve).collect();
|
||||
let mut p = json!({"u": u});
|
||||
if !v.is_empty() {
|
||||
p["v"] = json!(v);
|
||||
}
|
||||
if *join {
|
||||
p["operation"] = json!("join");
|
||||
}
|
||||
("surface.boundary", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
Surf::Patch { continuity } => {
|
||||
need(0, "a boundary (sketch profiles or a loop of edges)")?;
|
||||
let profiles: Vec<(u64, usize)> =
|
||||
@@ -629,6 +787,46 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
],
|
||||
)
|
||||
}
|
||||
FeatureKind::SurfaceBoundary { curves, orders, crown, samples, .. } => {
|
||||
let first = orders.first().copied().unwrap_or(solidcraft_engine::doc::kernel::Continuity::G1);
|
||||
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == first.as_str());
|
||||
// What the dialog does not show is kept as it was: a crown, the stations, and orders
|
||||
// that differ from side to side or that the list does not offer (G3).
|
||||
let mut extra = Map::new();
|
||||
if *crown != 0.0 {
|
||||
extra.insert("crown".into(), json!(crown));
|
||||
}
|
||||
if *samples != solidcraft_engine::doc::BOUNDARY_SAMPLES {
|
||||
extra.insert("samples".into(), json!(samples));
|
||||
}
|
||||
if index.is_none() || orders.iter().any(|o| *o != first) {
|
||||
extra.insert("continuity".into(), json!(orders.iter().map(|o| o.as_str()).collect::<Vec<_>>()));
|
||||
}
|
||||
let k = Surf::Boundary { continuity: index.unwrap_or(1), join: false };
|
||||
let mut ins = inputs(&k);
|
||||
if let Some(inp) = ins.first_mut() {
|
||||
inp.items = curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
|
||||
}
|
||||
return Some((Kind::Surface(k), ins, extra));
|
||||
}
|
||||
FeatureKind::SurfaceNetwork { u, v, operation } => {
|
||||
// A network with a point for a side has nothing to pick it with here.
|
||||
if u.iter().chain(v).any(|c| c.point.is_some()) {
|
||||
return None;
|
||||
}
|
||||
let sels = |list: &[solidcraft_engine::doc::NetworkCurve]| -> Vec<Sel> {
|
||||
list.iter()
|
||||
.flat_map(|c| {
|
||||
let mut out: Vec<Sel> = c.curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
|
||||
out.extend(c.edges.iter().filter_map(edge));
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let g = u.iter().chain(v).find(|c| c.body.is_some()).map_or(solidcraft_engine::doc::kernel::Continuity::G0, |c| c.continuity);
|
||||
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == g.as_str()).unwrap_or(0);
|
||||
(Surf::Boundary { continuity: index, join: *operation == solidcraft_engine::doc::Operation::Join }, vec![sels(u), sels(v)])
|
||||
}
|
||||
FeatureKind::SurfaceLoft { sections, .. } => {
|
||||
(Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch.0, sec.profiles.as_ref(), &sec.curves)).collect()])
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ struct Edit {
|
||||
/// Type-first creation: the value took the keyboard as soon as the picks determined the
|
||||
/// dimension, and its text follows the pointer until a click pins it or Enter places it.
|
||||
live: Option<Live>,
|
||||
selection: Option<egui::text::CCursorRange>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -132,7 +133,9 @@ pub fn begin_dimension(app: &mut SolveApp, entities: Vec<String>, at: Vec2) -> R
|
||||
app.set_status("The sketch already determines that dimension, so it was added as a driven (reference) dimension", false);
|
||||
return Ok(());
|
||||
}
|
||||
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft), live: None }));
|
||||
EDIT.with(|edit| {
|
||||
*edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft), live: None, selection: None })
|
||||
});
|
||||
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
|
||||
Ok(())
|
||||
}
|
||||
@@ -147,7 +150,7 @@ pub fn begin_live(app: &mut SolveApp, entities: Vec<String>, pointer: Option<Pos
|
||||
return Ok(false);
|
||||
}
|
||||
let live = Some(Live { origin: pointer, moved: false, typed: false });
|
||||
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft), live }));
|
||||
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft), live, selection: None }));
|
||||
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
|
||||
Ok(true)
|
||||
}
|
||||
@@ -351,7 +354,9 @@ pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool {
|
||||
return true;
|
||||
};
|
||||
let expr = app.session.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default();
|
||||
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0, draft: None, live: None }));
|
||||
EDIT.with(|e| {
|
||||
*e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0, draft: None, live: None, selection: None })
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
@@ -577,7 +582,52 @@ fn pos2_mid_top(r: Rect) -> Pos2 {
|
||||
/// its value is selected, so typing replaces it).
|
||||
pub fn edit_param(app: &SolveApp, param: &str) {
|
||||
let expr = app.session.doc.param(param).map(|p| p.expr.clone()).unwrap_or_default();
|
||||
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10, draft: None, live: None }));
|
||||
EDIT.with(|e| {
|
||||
*e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10, draft: None, live: None, selection: None })
|
||||
});
|
||||
}
|
||||
|
||||
/// Insert a reference at the expression's selection, using character offsets (not bytes).
|
||||
fn insert_reference(ctx: &egui::Context, edit: &mut Edit, name: &str) {
|
||||
let id = egui::Id::new("sc_dim_edit");
|
||||
let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default();
|
||||
let len = edit.expr.chars().count();
|
||||
let (start, end) = edit
|
||||
.selection
|
||||
.or_else(|| state.cursor.char_range())
|
||||
.map(|r| {
|
||||
let a = r.primary.index.0.min(len);
|
||||
let b = r.secondary.index.0.min(len);
|
||||
(a.min(b), a.max(b))
|
||||
})
|
||||
.unwrap_or((len, len));
|
||||
let before: String = edit.expr.chars().take(start).collect();
|
||||
let after: String = edit.expr.chars().skip(end).collect();
|
||||
edit.expr = format!("{before}{name}{after}");
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(start + name.chars().count()))));
|
||||
edit.selection = state.cursor.char_range();
|
||||
state.store(ctx, id);
|
||||
ctx.memory_mut(|m| m.request_focus(id));
|
||||
edit.fresh = FRESH - 1;
|
||||
if let Some(live) = edit.live.as_mut() {
|
||||
live.typed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume dimension clicks while editing; only a double-click inserts a reference.
|
||||
pub fn pick_reference(ctx: &egui::Context, pos: Pos2, double: bool) -> bool {
|
||||
EDIT.with(|editor| {
|
||||
let mut editor = editor.borrow_mut();
|
||||
let Some(edit) = editor.as_mut() else { return false };
|
||||
let Some(id) = hit(pos) else { return false };
|
||||
let param = DRAWN.with(|d| d.borrow().iter().find(|d| d.id == id).and_then(|d| d.param.clone()));
|
||||
if double && let Some(param) = param.filter(|p| p != &edit.param) {
|
||||
insert_reference(ctx, edit, ¶m);
|
||||
}
|
||||
// Self references and driven dimensions cannot supply a reference.
|
||||
edit.fresh = FRESH - 1;
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
@@ -612,8 +662,16 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
crate::frame::area(egui::Id::new("sc_dim_edit_area")).fixed_pos(anchor).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
|
||||
ui.set_max_width(popup_width);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(popup_width.min(90.0)));
|
||||
ui.label(egui::RichText::new(format!("Dimension: {}", e.param)).strong())
|
||||
.on_hover_text("Double-click another driving dimension to insert its name");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(popup_width));
|
||||
if r.has_focus() {
|
||||
e.selection = egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range());
|
||||
}
|
||||
let completed = crate::params_dialog::complete(ui, &r, &mut e.expr);
|
||||
if completed {
|
||||
e.selection = egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range());
|
||||
}
|
||||
if let Some(live) = e.live.as_mut() {
|
||||
live.typed |= r.changed() || completed;
|
||||
}
|
||||
@@ -625,6 +683,7 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let n = e.expr.chars().count();
|
||||
let mut s = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default();
|
||||
s.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
|
||||
e.selection = s.cursor.char_range();
|
||||
s.store(ui.ctx(), id);
|
||||
}
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
|
||||
@@ -32,6 +32,57 @@ fn type_value(h: &mut Harness, text: &str) {
|
||||
h.step(&json!({"text":text})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_editor_names_and_reference_picks_drive_new_and_existing_dimensions() {
|
||||
let mut h = line([20., 0.]);
|
||||
let source =
|
||||
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":20,"text_at":[10,8]})).unwrap()["param"].as_str().unwrap().to_string();
|
||||
h.app.run("sketch.line", json!({"points":[[0,-10],[12,-10]],"infer":false})).unwrap();
|
||||
h.settle();
|
||||
place(&mut h, &[[6., -10.]], [6., -16.]);
|
||||
let target = h.app.session.doc.next_d_name();
|
||||
assert!(h.widget(&format!("Dimension: {target}")).is_some(), "new dimension shows its actual parameter name");
|
||||
// Let the preceding placement clicks expire before starting a distinct double-click.
|
||||
h.frames(40);
|
||||
h.step(&json!({"click":{"dimension":source},"double":true})).unwrap();
|
||||
assert!(crate::dim_view::editing(), "reference click keeps the draft open");
|
||||
h.step(&json!({"text":" / 2"})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(!crate::dim_view::editing(), "{}", h.call("ui.inspect", json!({})));
|
||||
assert_eq!(h.app.session.doc.param(&target).unwrap().expr, format!("{source} / 2"));
|
||||
h.app.tool = None;
|
||||
h.settle();
|
||||
crate::dim_view::edit_param(&h.app, &target);
|
||||
h.frames(5);
|
||||
assert!(h.widget(&format!("Dimension: {target}")).is_some());
|
||||
type_value(&mut h, "2 * ");
|
||||
h.frames(40);
|
||||
h.step(&json!({"click":{"dimension":source},"double":true})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert_eq!(h.app.session.doc.param(&target).unwrap().expr, format!("2 * {source}"));
|
||||
h.app.run("parameters.change", json!({"name":source,"expression":"15 mm"})).unwrap();
|
||||
assert!((h.app.session.doc.eval(&target, solidcraft_engine::doc::expr::Kind::Length).unwrap() - 30.).abs() < 1e-6);
|
||||
crate::dim_view::cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_clicking_another_dimension_does_not_change_the_expression() {
|
||||
let mut h = line([20., 0.]);
|
||||
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":20,"text_at":[10,8]})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,-10],[12,-10]],"infer":false})).unwrap();
|
||||
h.app.run("sketch.dimension", json!({"entities":["l2"],"value":12,"text_at":[6,-16]})).unwrap();
|
||||
h.settle();
|
||||
crate::dim_view::edit_param(&h.app, "d2");
|
||||
h.frames(5);
|
||||
type_value(&mut h, "8");
|
||||
h.step(&json!({"click":{"dimension":"d1"}})).unwrap();
|
||||
assert!(crate::dim_view::editing());
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert_eq!(h.app.session.doc.param("d2").unwrap().expr, "8");
|
||||
crate::dim_view::cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_parameters_use_typing_and_ctrl_space_completion() {
|
||||
for (prefix, expected) in [("wid", "width"), ("", "width"), ("d", "d1")] {
|
||||
|
||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user