Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c59842b8e1 | ||
|
|
2f31138f9d | ||
|
|
8c503ad1be | ||
|
|
a587d3339e | ||
|
|
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
|
||||
@@ -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 test shards 1-2 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' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || '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,39 @@ 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' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || '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 self-hosted runner keeps its build directory and Cargo home instead.
|
||||
- if: runner.environment == 'github-hosted'
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: repo-checks
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -163,12 +213,12 @@ 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' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || '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:
|
||||
@@ -193,7 +243,7 @@ jobs:
|
||||
name: Build tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -236,12 +286,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' && matrix.shard <= 2) && '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 +317,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 +329,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 +434,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' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -402,7 +444,7 @@ 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.
|
||||
@@ -416,13 +458,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 +493,13 @@ 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 self-hosted runner keeps its build directory and Cargo home instead.
|
||||
- if: runner.environment == 'github-hosted'
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: oracle
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -443,7 +510,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 +579,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' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
@@ -520,6 +587,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 +603,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 }}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4049,7 +4049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4069,6 +4069,7 @@ dependencies = [
|
||||
"ron",
|
||||
"rustls",
|
||||
"serde_json",
|
||||
"solidcraft-3dx-client",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-space-mouse",
|
||||
"solidcraft-ui-egui",
|
||||
@@ -4077,9 +4078,13 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -4090,7 +4095,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4099,7 +4104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4116,7 +4121,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4133,7 +4138,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4142,7 +4147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4159,7 +4164,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4179,7 +4184,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4199,7 +4204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4208,7 +4213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4222,7 +4227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4233,11 +4238,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -4261,7 +4266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5791,7 +5796,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -274,6 +274,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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -86,6 +86,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 +129,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 +165,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 +558,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 +799,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 +1305,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",
|
||||
@@ -1440,6 +1448,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 +1472,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",
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"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",
|
||||
"Double-click to rename": "Doble clic para cambiar el nombre",
|
||||
@@ -120,6 +121,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",
|
||||
@@ -153,6 +155,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
@@ -127,6 +128,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",
|
||||
@@ -161,6 +163,7 @@
|
||||
"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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "出力しない",
|
||||
"Don't ask again": "今後確認しない",
|
||||
@@ -128,6 +129,7 @@
|
||||
"Home view": "ホームビュー",
|
||||
"In This Design": "このデザイン内",
|
||||
"In place (surfaces)": "その場で(サーフェス)",
|
||||
"In plane": "平面内",
|
||||
"Inside": "内側",
|
||||
"Interferes": "干渉あり",
|
||||
"Issue title": "Issueタイトル",
|
||||
@@ -163,6 +165,7 @@
|
||||
"No matching appearances": "一致する外観がありません",
|
||||
"No visible bodies to export.": "出力する表示中のボディがありません。",
|
||||
"None": "なし",
|
||||
"Normal to sketch": "スケッチに垂直",
|
||||
"Not available yet": "まだ利用できません",
|
||||
"Notch": "ノッチ",
|
||||
"Opacity": "不透明度",
|
||||
@@ -555,6 +558,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 +799,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 +1153,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 +1305,7 @@
|
||||
"ui.one_side": "片側",
|
||||
"ui.open": "開く",
|
||||
"ui.open_ellipsis": "開く…",
|
||||
"ui.open_ends_tip": "どこにも接していない",
|
||||
"ui.open_preferences": "環境設定を開く",
|
||||
"ui.open_recent": "最近使った項目を開く",
|
||||
"ui.operation": "操作",
|
||||
@@ -1438,6 +1448,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 +1472,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": "Непрозорість",
|
||||
|
||||
@@ -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": "不导出隐藏线",
|
||||
"Don't ask again": "不再询问",
|
||||
@@ -128,6 +129,7 @@
|
||||
"Home view": "主视图",
|
||||
"In This Design": "在此设计中",
|
||||
"In place (surfaces)": "原位(曲面)",
|
||||
"In plane": "在平面内",
|
||||
"Inside": "内侧",
|
||||
"Interferes": "发生干涉",
|
||||
"Issue title": "Issue 标题",
|
||||
@@ -163,6 +165,7 @@
|
||||
"No matching appearances": "没有匹配的外观",
|
||||
"No visible bodies to export.": "没有可导出的可见实体。",
|
||||
"None": "无",
|
||||
"Normal to sketch": "垂直于草图",
|
||||
"Not available yet": "暂不可用",
|
||||
"Notch": "缺口",
|
||||
"Opacity": "不透明度",
|
||||
@@ -555,6 +558,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 +799,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 +1153,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 +1305,7 @@
|
||||
"ui.one_side": "单侧",
|
||||
"ui.open": "打开",
|
||||
"ui.open_ellipsis": "打开...",
|
||||
"ui.open_ends_tip": "未接触任何曲线",
|
||||
"ui.open_preferences": "打开首选项",
|
||||
"ui.open_recent": "打开最近",
|
||||
"ui.operation": "操作",
|
||||
@@ -1438,6 +1448,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 +1472,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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -1104,7 +1106,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 +1591,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 +1669,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 +2209,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 +3287,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 +3361,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 {
|
||||
@@ -4576,8 +4745,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 +5904,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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -59,6 +59,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;
|
||||
@@ -173,7 +175,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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,6 +76,8 @@ mod construct_tests;
|
||||
#[cfg(test)]
|
||||
mod continuation_tests;
|
||||
#[cfg(test)]
|
||||
mod ends_tests;
|
||||
#[cfg(test)]
|
||||
mod font_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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")] {
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Straight extrusion feedback from existing display triangles, without a scratch design.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_engine::{
|
||||
Sel,
|
||||
doc::Document,
|
||||
geom::{Aabb3, Seg2, Vec3},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
SolveApp,
|
||||
gpu::GpuScene,
|
||||
preview::{Built, Colors},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct StraightPreview {
|
||||
cached: Option<Cached>,
|
||||
}
|
||||
|
||||
struct Cached {
|
||||
document: Arc<Document>,
|
||||
revision: u64,
|
||||
inputs: Vec<Sel>,
|
||||
surfaces: Vec<Surface>,
|
||||
bounds: Aabb3,
|
||||
}
|
||||
struct Surface {
|
||||
normal: Vec3,
|
||||
triangles: Vec<[Vec3; 3]>,
|
||||
edges: Vec<[Vec3; 2]>,
|
||||
}
|
||||
|
||||
impl StraightPreview {
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.cached = None;
|
||||
}
|
||||
pub(crate) fn compute(&mut self, app: &SolveApp, commands: &[(String, Value)], colors: Colors) -> Option<Result<Built, String>> {
|
||||
let [(id, params)] = commands else { return None };
|
||||
let params = match id.as_str() {
|
||||
"solid.extrude" => params,
|
||||
"timeline.redefine" if params.get("command").and_then(Value::as_str) == Some("solid.extrude") => params.get("params")?,
|
||||
_ => return None,
|
||||
};
|
||||
// A newly opened STEP is now a component. Its single identity placement
|
||||
// has the same authoring coordinates; other placements keep Engine preview.
|
||||
if app.session.active_component != 0 || app.session.active_occurrence_path.is_some() {
|
||||
let path = app.session.active_occurrence_path.as_ref()?;
|
||||
let placed = app.session.occurrence_placement(path).ok()?;
|
||||
if path.len() != 1
|
||||
|| placed.component != app.session.active_component
|
||||
|| placed.world != solidcraft_engine::doc::IDENTITY
|
||||
|| app.session.doc.occurrences.iter().filter(|o| o.component == placed.component).count() != 1
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if app.session.doc.units != "mm"
|
||||
|| ["distance2", "start_offset", "from", "to", "to_offset", "thin"].iter().any(|key| params.get(*key).is_some())
|
||||
|| params.get("through_all").and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let direction = params.get("direction").and_then(Value::as_str).unwrap_or("positive");
|
||||
if !matches!(direction, "positive" | "negative") {
|
||||
return None;
|
||||
}
|
||||
if let Some(taper) = params.get("taper") {
|
||||
let expression = taper.as_str().map(str::to_owned).unwrap_or_else(|| taper.to_string());
|
||||
if app.session.doc.eval(&expression, solidcraft_engine::doc::expr::Kind::Angle).ok()?.abs() > 1e-12 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let distance = params.get("distance")?;
|
||||
let expression = distance.as_str().map(str::to_owned).unwrap_or_else(|| distance.to_string());
|
||||
let mut distance = app.session.doc.eval(&expression, solidcraft_engine::doc::expr::Kind::Length).ok()?;
|
||||
if direction == "negative" {
|
||||
distance = -distance;
|
||||
}
|
||||
if !distance.is_finite() || distance.abs() < 1e-9 || distance.abs() > 1e6 {
|
||||
return None;
|
||||
}
|
||||
let operation = params.get("operation").and_then(Value::as_str).unwrap_or("new");
|
||||
if !matches!(operation, "new" | "join" | "cut" | "intersect") {
|
||||
return None;
|
||||
}
|
||||
let inputs = &app.dialog.as_ref()?.inputs.first()?.items;
|
||||
if inputs.is_empty() || inputs.len() > 1024 {
|
||||
return None;
|
||||
}
|
||||
if self
|
||||
.cached
|
||||
.as_ref()
|
||||
.is_none_or(|cache| cache.revision != app.session.revision || !Arc::ptr_eq(&cache.document, &app.session.doc) || cache.inputs != *inputs)
|
||||
{
|
||||
self.cached = prepare(app, inputs);
|
||||
}
|
||||
let cache = self.cached.as_ref()?;
|
||||
let mut scene = GpuScene::default();
|
||||
let mut bounds = cache.bounds;
|
||||
let color = if operation == "cut" {
|
||||
colors.removed
|
||||
} else if operation == "intersect" {
|
||||
[colors.added[0], colors.added[1], colors.added[2], colors.removed[3]]
|
||||
} else {
|
||||
colors.added
|
||||
};
|
||||
let triangle = |scene: &mut GpuScene, points: [Vec3; 3], normal: Vec3| {
|
||||
for point in points {
|
||||
if matches!(operation, "cut" | "intersect") {
|
||||
scene.xray_tri(point.to_f32(), normal.to_f32(), color);
|
||||
} else {
|
||||
scene.tri(point.to_f32(), normal.to_f32(), color);
|
||||
}
|
||||
}
|
||||
};
|
||||
for surface in &cache.surfaces {
|
||||
let shift = surface.normal * distance;
|
||||
let (low, high) = if distance > 0. { (Vec3::ZERO, shift) } else { (shift, Vec3::ZERO) };
|
||||
for &[a, b, c] in &surface.triangles {
|
||||
triangle(&mut scene, [a + low, c + low, b + low], -surface.normal);
|
||||
triangle(&mut scene, [a + high, b + high, c + high], surface.normal);
|
||||
for point in [a + low, b + low, c + low, a + high, b + high, c + high] {
|
||||
bounds.add(point);
|
||||
}
|
||||
}
|
||||
let edge_color = if operation == "cut" { [color[0], color[1], color[2], 255] } else { colors.edge };
|
||||
for &[a, b] in &surface.edges {
|
||||
let normal = (b - a).cross(surface.normal).normalized()?;
|
||||
triangle(&mut scene, [a + low, b + low, b + high], normal);
|
||||
triangle(&mut scene, [a + low, b + high, a + high], normal);
|
||||
for (a, b) in [(a + low, b + low), (a + high, b + high), (a + low, a + high)] {
|
||||
scene.line(a.to_f32(), b.to_f32(), edge_color, 1.3, operation != "join");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Built {
|
||||
scene,
|
||||
sketches: Vec::new(),
|
||||
applied: None,
|
||||
stopped: None,
|
||||
tapered: Vec::new(),
|
||||
replaced: Vec::new(),
|
||||
error: crate::preview::extrusion_target_error(&app.session, commands, &app.session.model.state()),
|
||||
bounds,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(app: &SolveApp, inputs: &[Sel]) -> Option<Cached> {
|
||||
let state = app.session.world_state();
|
||||
let mut surfaces = Vec::new();
|
||||
let mut budget = 65_536usize;
|
||||
if inputs.iter().all(|input| matches!(input, Sel::Profile { .. })) {
|
||||
let Sel::Profile { sketch: owner, .. } = inputs.first()? else { return None };
|
||||
if inputs.iter().any(|input| !matches!(input,Sel::Profile {sketch,..} if sketch==owner)) {
|
||||
return None;
|
||||
}
|
||||
for input in inputs {
|
||||
let Sel::Profile { sketch, index } = input else { return None };
|
||||
let sketch = state.sketch(*sketch)?;
|
||||
let region = &sketch.profiles.get(*index)?.region;
|
||||
let loops = std::iter::once(®ion.outer).chain(®ion.holes);
|
||||
if loops.clone().flat_map(|loop_| &loop_.segs).any(|segment| !matches!(segment, Seg2::Line { .. })) {
|
||||
return None;
|
||||
}
|
||||
let triangles = crate::sketch_display::fill_triangles(region, &mut budget)
|
||||
.into_iter()
|
||||
.map(|triangle| triangle.map(|point| sketch.plane.to_world(point)))
|
||||
.collect::<Vec<_>>();
|
||||
if triangles.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let edges = std::iter::once((®ion.outer, true))
|
||||
.chain(region.holes.iter().map(|hole| (hole, false)))
|
||||
.flat_map(|(loop_, outer)| {
|
||||
let reverse = (loop_.signed_area() > 0.) != outer;
|
||||
loop_.segs.iter().map(move |segment| {
|
||||
let pair = [sketch.plane.to_world(segment.start()), sketch.plane.to_world(segment.end())];
|
||||
if reverse { [pair[1], pair[0]] } else { pair }
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
surfaces.push(Surface { normal: sketch.plane.normal(), triangles, edges });
|
||||
}
|
||||
} else if inputs.iter().all(|input| matches!(input, Sel::Face { .. })) {
|
||||
// One pass over each selected body's triangles, rather than one full scan per pick.
|
||||
for body in &state.bodies {
|
||||
let mut selected = HashMap::new();
|
||||
for input in inputs {
|
||||
if let Sel::Face { body: name, index, point } = input
|
||||
&& name == &body.name
|
||||
{
|
||||
let face = u32::try_from(*index).ok()?;
|
||||
if selected.contains_key(&face) {
|
||||
return None;
|
||||
}
|
||||
let normal = body.body.planar_face(*index)?.normal();
|
||||
selected.insert(face, (surfaces.len(), *point));
|
||||
surfaces.push(Surface { normal, triangles: Vec::new(), edges: Vec::new() });
|
||||
}
|
||||
}
|
||||
if selected.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mesh = body.mesh();
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if let Some((index, _)) = selected.get(face) {
|
||||
budget = budget.checked_sub(1)?;
|
||||
let mut points = mesh.tri(triangle)?;
|
||||
let surface = surfaces.get_mut(*index)?;
|
||||
if (points[1] - points[0]).cross(points[2] - points[0]).dot(surface.normal) < 0. {
|
||||
points.swap(1, 2);
|
||||
}
|
||||
surface.triangles.push(points);
|
||||
}
|
||||
}
|
||||
// The command resolves picks by their point. A stale display face index
|
||||
// must fall back rather than previewing a different face.
|
||||
let tolerance = (mesh.bounds().diagonal() * 1e-8).max(1e-8);
|
||||
for (index, point) in selected.values() {
|
||||
if !surfaces.get(*index)?.triangles.iter().any(|triangle| crate::dialogs::point_tri_dist(*point, *triangle) < tolerance) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if surfaces.len() != inputs.len() {
|
||||
return None;
|
||||
}
|
||||
for surface in &mut surfaces {
|
||||
let key = |point: Vec3| [point.x, point.y, point.z].map(|coordinate| if coordinate == 0. { 0 } else { coordinate.to_bits() });
|
||||
let mut edges = HashMap::new();
|
||||
for &[a, b, c] in &surface.triangles {
|
||||
for (a, b) in [(a, b), (b, c), (c, a)] {
|
||||
let (ka, kb) = (key(a), key(b));
|
||||
let entry = edges.entry(if ka < kb { (ka, kb) } else { (kb, ka) });
|
||||
match entry {
|
||||
std::collections::hash_map::Entry::Vacant(entry) => {
|
||||
entry.insert(Some([a, b]));
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(mut entry) => {
|
||||
entry.get().as_ref()?;
|
||||
entry.insert(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
surface.edges = edges.into_values().flatten().collect();
|
||||
if surface.triangles.is_empty() || surface.edges.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
let bounds = state.bodies.iter().fold(Aabb3::EMPTY, |bounds, body| bounds.union(&body.mesh().bounds()));
|
||||
Some(Cached { document: app.session.doc.clone(), revision: app.session.revision, inputs: inputs.to_vec(), surfaces, bounds })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{dialogs::Kind, scenario::Harness};
|
||||
use serde_json::json;
|
||||
|
||||
fn mesh_volume(scene: &GpuScene) -> f64 {
|
||||
scene
|
||||
.tris
|
||||
.as_chunks::<{ crate::gpu::TRI_SIZE * 3 }>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|triangle| {
|
||||
let points: Vec<_> = triangle
|
||||
.as_chunks::<{ crate::gpu::TRI_SIZE }>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|vertex| {
|
||||
let numbers: Vec<_> =
|
||||
vertex.get(..12).unwrap().as_chunks::<4>().0.iter().map(|bytes| f32::from_le_bytes(*bytes) as f64).collect();
|
||||
Vec3::new(numbers[0], numbers[1], numbers[2])
|
||||
})
|
||||
.collect();
|
||||
points[0].dot(points[1].cross(points[2])) / 6.
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
#[test]
|
||||
fn face_mesh_feedback_reuses_cap_and_handles_both_distance_signs() {
|
||||
let mut harness = Harness::new();
|
||||
harness.app.run("solid.box", json!({"length":4,"width":3,"height":5,"body_name":"Stock"})).unwrap();
|
||||
let state = harness.app.session.world_state();
|
||||
let face = state
|
||||
.body("Stock")
|
||||
.unwrap()
|
||||
.body
|
||||
.faces(0.001)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.99))
|
||||
.unwrap();
|
||||
harness.app.session.selection = vec![Sel::Face { body: "Stock".into(), index: face.index, point: face.centroid }];
|
||||
harness.app.start("solid.extrude");
|
||||
let original = harness.app.session.doc.clone();
|
||||
let undo = harness.app.session.undo.len();
|
||||
let mut preview = StraightPreview::default();
|
||||
let colors = Colors::from_tokens(&crate::theme::Tokens::get());
|
||||
let mut pointer = None;
|
||||
for distance in [2.0_f64, -2., 3.] {
|
||||
if let Kind::Extrude { distance: value, operation, auto_op, .. } = &mut harness.app.dialog.as_mut().unwrap().kind {
|
||||
*value = distance.to_string();
|
||||
*operation = 0;
|
||||
*auto_op = false;
|
||||
}
|
||||
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let scene = preview.compute(&harness.app, &commands, colors).unwrap().unwrap();
|
||||
assert!((mesh_volume(&scene.scene) - 12. * distance.abs()).abs() < 1e-5);
|
||||
let current = preview.cached.as_ref().unwrap().surfaces[0].triangles.as_ptr();
|
||||
if let Some(pointer) = pointer {
|
||||
assert_eq!(pointer, current);
|
||||
} else {
|
||||
pointer = Some(current);
|
||||
}
|
||||
}
|
||||
assert!(Arc::ptr_eq(&original, &harness.app.session.doc));
|
||||
assert_eq!(harness.app.session.undo.len(), undo);
|
||||
harness.app.run("solid.box", json!({"length":1,"width":1,"height":1,"corner":[30,0,0]})).unwrap();
|
||||
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
|
||||
preview.compute(&harness.app, &commands, colors).unwrap().unwrap();
|
||||
assert!(Arc::ptr_eq(&preview.cached.as_ref().unwrap().document, &harness.app.session.doc));
|
||||
assert!(!Arc::ptr_eq(&original, &preview.cached.as_ref().unwrap().document));
|
||||
if let Sel::Face { point, .. } = &mut harness.app.dialog.as_mut().unwrap().inputs[0].items[0] {
|
||||
*point = Vec3::new(100., 100., 100.);
|
||||
}
|
||||
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!(preview.compute(&harness.app, &commands, colors).is_none());
|
||||
let mut commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
|
||||
commands[0].1["through_all"] = json!(true);
|
||||
assert!(preview.compute(&harness.app, &commands, colors).is_none());
|
||||
commands[0].1 = json!({"distance":2,"taper":"3deg"});
|
||||
assert!(preview.compute(&harness.app, &commands, colors).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn identity_component_feedback_skips_moved_and_repeated_placements() {
|
||||
let mut h = Harness::new();
|
||||
let made = h.app.run("component.create", json!({"name":"Imported part"})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":4,"width":3,"height":5,"body_name":"Stock"})).unwrap();
|
||||
let world = h.app.session.world_state();
|
||||
let face =
|
||||
world.body("Stock").unwrap().body.faces(0.001).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|n| n.z > 0.99)).unwrap();
|
||||
h.app.session.selection = vec![Sel::Face { body: "Stock".into(), index: face.index, point: face.centroid }];
|
||||
h.app.start("solid.extrude");
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let colors = Colors::from_tokens(&crate::theme::Tokens::get());
|
||||
let mut preview = StraightPreview::default();
|
||||
assert!(preview.compute(&h.app, &commands, colors).unwrap().is_ok());
|
||||
h.app.run("occurrence.move", json!({"occurrence":made["occurrence"],"translate":[20,0,0]})).unwrap();
|
||||
assert!(preview.compute(&h.app, &commands, colors).is_none());
|
||||
h.app.run("occurrence.move", json!({"occurrence":made["occurrence"],"translate":[-20,0,0]})).unwrap();
|
||||
h.app.run("occurrence.copy", json!({"component":made["component"],"translate":[30,0,0]})).unwrap();
|
||||
assert!(preview.compute(&h.app, &commands, colors).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_feedback_keeps_holes_and_skips_unsupported_curves() {
|
||||
let mut harness = Harness::new();
|
||||
harness.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
for (a, b) in [([0., 0.], [10., 10.]), ([3., 3.], [7., 7.])] {
|
||||
harness.app.run("sketch.rectangle.two_point", json!({"p0":a,"p1":b})).unwrap();
|
||||
}
|
||||
harness.app.run("sketch.finish", json!({})).unwrap();
|
||||
let sketch = harness.app.session.model.state().sketches[0].clone();
|
||||
let index = sketch.profiles.iter().position(|profile| !profile.region.holes.is_empty()).unwrap();
|
||||
harness.app.session.selection = vec![Sel::Profile { sketch: sketch.feature, index }];
|
||||
harness.app.start("solid.extrude");
|
||||
if let Kind::Extrude { distance, operation, auto_op, .. } = &mut harness.app.dialog.as_mut().unwrap().kind {
|
||||
*distance = "2".into();
|
||||
*operation = 0;
|
||||
*auto_op = false;
|
||||
}
|
||||
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let scene = StraightPreview::default().compute(&harness.app, &commands, Colors::from_tokens(&crate::theme::Tokens::get())).unwrap().unwrap();
|
||||
assert!((mesh_volume(&scene.scene) - 168.).abs() < 1e-5);
|
||||
harness.app.dialog = None;
|
||||
harness.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
harness.app.run("sketch.circle.center", json!({"center":[20,0],"diameter":4})).unwrap();
|
||||
harness.app.run("sketch.finish", json!({})).unwrap();
|
||||
let feature = harness.app.session.model.state().sketches.last().unwrap().feature;
|
||||
harness.app.session.selection = vec![Sel::Profile { sketch: feature, index: 0 }];
|
||||
harness.app.start("solid.extrude");
|
||||
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!(StraightPreview::default().compute(&harness.app, &commands, Colors::from_tokens(&crate::theme::Tokens::get())).is_none());
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ mod apply_off_thread_tests;
|
||||
mod expression_complete;
|
||||
#[cfg(test)]
|
||||
mod extrude_edit_tests;
|
||||
mod extrude_preview;
|
||||
mod file_load;
|
||||
mod file_picker;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -6,6 +6,51 @@ use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
use solidcraft_engine::{Sel, Session};
|
||||
|
||||
#[test]
|
||||
fn confirming_coplanar_body_faces_uses_one_atomic_extrusion() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let parent = h.app.run("component.create", json!({"name":"Imported assembly"})).unwrap()["component"].clone();
|
||||
h.app.run("component.create", json!({"name":"Part"})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":30,"width":20,"height":20,"body_name":"Plate"})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":{"origin":[0,0,20],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
|
||||
for x in [2, 10] {
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[x,2],"p1":[x+4,6]})).unwrap();
|
||||
}
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("solid.extrude", json!({"distance":6,"operation":"join"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":parent})).unwrap();
|
||||
let world = h.app.session.world_state();
|
||||
let body = world.body("Plate").unwrap();
|
||||
h.app.session.selection = body
|
||||
.body
|
||||
.faces(0.01)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.99) && (face.centroid.z - 26.0).abs() < 1e-8)
|
||||
.map(|face| Sel::Face { body: "Plate".into(), index: face.index, point: face.centroid })
|
||||
.collect();
|
||||
assert_eq!(h.app.session.selection.len(), 2);
|
||||
h.app.start("solid.extrude");
|
||||
if let Kind::Extrude { distance, .. } = &mut h.app.dialog.as_mut().unwrap().kind {
|
||||
*distance = "-7".into();
|
||||
}
|
||||
h.frames(3);
|
||||
let commands = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands.first().unwrap().1["faces"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(commands.first().unwrap().1["face_body"], "Plate");
|
||||
assert_eq!(commands.first().unwrap().1["operation"], "cut");
|
||||
assert!(h.app.preview.error.is_none(), "{:?}", h.app.preview.error);
|
||||
let undo = h.app.session.undo.len();
|
||||
h.frames(3);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "busy={}, dialog={:?}", h.app.applying.is_some(), h.app.dialog);
|
||||
assert_eq!(h.app.session.doc.features.len(), 5);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let volume = solidcraft_engine::doc::kernel::measure(&h.app.session.model.state().body("Plate").unwrap().body).unwrap().volume;
|
||||
assert!((volume - 11_968.0).abs() < 1e-5, "{volume}");
|
||||
}
|
||||
|
||||
use crate::dialogs::{Dialog, Kind, apply_commands};
|
||||
use crate::{Services, SolveApp};
|
||||
|
||||
|
||||
@@ -98,10 +98,12 @@ pub struct PreviewState {
|
||||
pub stopped: Option<serde_json::Value>,
|
||||
/// The radius splines the previewed fillet tapered its edges with.
|
||||
pub tapered: Vec<(Vec3, solidcraft_engine::kernel::Taper)>,
|
||||
straight: crate::extrude_preview::StraightPreview,
|
||||
}
|
||||
|
||||
impl PreviewState {
|
||||
fn clear(&mut self) {
|
||||
self.straight.clear();
|
||||
self.running = None;
|
||||
self.running_tool = false;
|
||||
self.running_stream_key = None;
|
||||
@@ -293,6 +295,14 @@ pub fn update(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if pv.running.is_none() && pv.shown_key != Some(key) {
|
||||
pv.started = crate::now_ms();
|
||||
pv.running_stream_key = stream_key;
|
||||
let mut straight = std::mem::take(&mut app.preview.straight);
|
||||
let job = straight.compute(app, &cmds, colors);
|
||||
app.preview.straight = straight;
|
||||
if let Some(job) = job {
|
||||
app.preview.apply(key, job);
|
||||
app.preview.busy = false;
|
||||
return;
|
||||
}
|
||||
let scratch = app.session.scratch();
|
||||
spawn(&mut app.preview, key, scratch, cmds, colors, tool);
|
||||
}
|
||||
@@ -394,6 +404,51 @@ fn compute_tool(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
fn extrusion_params<'a>(id: &str, params: &'a Value) -> Option<&'a Value> {
|
||||
match id {
|
||||
"solid.extrude" => Some(params),
|
||||
"timeline.redefine" if params.get("command").and_then(Value::as_str) == Some("solid.extrude") => params.get("params"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check references without intersecting the tool with the target's geometry.
|
||||
pub(crate) fn extrusion_target_error(s: &Session, cmds: &[(String, Value)], before: &ModelState) -> Option<String> {
|
||||
for (id, params) in cmds {
|
||||
let params = extrusion_params(id, params)?;
|
||||
let op = params.get("operation").and_then(Value::as_str).unwrap_or("new");
|
||||
if op == "new" {
|
||||
continue;
|
||||
}
|
||||
let targets = params
|
||||
.get("targets")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|targets| !targets.is_empty())
|
||||
.or_else(|| params.get("participants").and_then(Value::as_array));
|
||||
let mut found = false;
|
||||
if let Some(targets) = targets.filter(|targets| !targets.is_empty()) {
|
||||
for target in targets.iter().filter_map(Value::as_str) {
|
||||
if target == "*" {
|
||||
found |= !before.bodies.is_empty();
|
||||
} else if before.body(target).is_some() {
|
||||
found = true;
|
||||
} else {
|
||||
return Some(format!("unknown body `{target}`"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
found = params.get("faces").and_then(|_| params.get("face_body")).and_then(Value::as_str).map_or_else(
|
||||
|| before.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component),
|
||||
|name| s.world_state().body(name).is_some(),
|
||||
);
|
||||
}
|
||||
if !found && matches!(op, "cut" | "intersect") {
|
||||
return Some("there is no body to cut or intersect".into());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn state_bounds(st: &ModelState) -> Aabb3 {
|
||||
st.bodies.iter().fold(Aabb3::EMPTY, |b, x| b.union(&x.mesh().bounds()))
|
||||
}
|
||||
|
||||
@@ -340,10 +340,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !visible(app, ctx) {
|
||||
return;
|
||||
}
|
||||
let (opts, dof_line, ok) = {
|
||||
let (opts, dof_line, ok, open_ends, untied_ends) = {
|
||||
let st = app.session.world_state();
|
||||
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return };
|
||||
(ss.sketch.view.clone(), dof_text(ss.report.dof, ss.report.ok()), ss.report.ok() && ss.report.dof == 0)
|
||||
(
|
||||
ss.sketch.view.clone(),
|
||||
dof_text(ss.report.dof, ss.report.ok()),
|
||||
ss.report.ok() && ss.report.dof == 0,
|
||||
ss.ends.open.len(),
|
||||
ss.ends.untied.len(),
|
||||
)
|
||||
};
|
||||
let t = Tokens::get();
|
||||
let vp = viewport(app, ctx);
|
||||
@@ -369,6 +375,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let mut linetype: Option<u8> = None;
|
||||
let mut look = false;
|
||||
let mut finish = false;
|
||||
let mut tie = false;
|
||||
let mut start: Option<&'static str> = None;
|
||||
let feature = active_feature(app);
|
||||
let has_curves = !selected_curves(app).is_empty();
|
||||
@@ -429,7 +436,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
});
|
||||
if !ofold {
|
||||
// Keep room for the footer: status heading and line, a line, Finish Sketch, a line.
|
||||
let footer = 5.0 * line + ui.spacing().interact_size.y + 6.0 * gap + 8.0;
|
||||
let ends_rows = if open_ends > 0 { line + gap } else { 0.0 } + if untied_ends > 0 { ui.spacing().interact_size.y + gap } else { 0.0 };
|
||||
let footer = 5.0 * line + ui.spacing().interact_size.y + 6.0 * gap + 8.0 + ends_rows;
|
||||
let options_height = (bottom - ui.cursor().top() - footer).max(24.0);
|
||||
let options =
|
||||
egui::ScrollArea::vertical().id_salt("sc_palette_options").max_height(options_height).auto_shrink([false, true]).show(ui, |ui| {
|
||||
@@ -537,6 +545,22 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.label(RichText::new(app.tr("ui.sketch_status")).size(12.0));
|
||||
});
|
||||
ui.label(RichText::new(crate::i18n::tr_dyn(&dof_line)).color(if ok { t.sketch_accent } else { t.text_dim }));
|
||||
// Where a profile can fail (the viewport rings and diamonds them).
|
||||
if open_ends > 0 {
|
||||
let text = crate::i18n::format("sketch.open_ends", &[("count", &open_ends.to_string())]);
|
||||
ui.label(RichText::new(text).color(t.warning)).on_hover_text(app.tr("ui.open_ends_tip"));
|
||||
}
|
||||
if untied_ends > 0 {
|
||||
ui.horizontal(|ui| {
|
||||
let text = crate::i18n::format("sketch.untied_ends", &[("count", &untied_ends.to_string())]);
|
||||
ui.label(RichText::new(text).color(t.warning)).on_hover_text(app.tr("ui.untied_ends_tip"));
|
||||
let button = ui.button(app.tr("ui.tie_ends")).on_hover_text(app.tr("ui.tie_ends_tip"));
|
||||
crate::scenario::publish_handle("sketch_palette:tie_ends", button.rect.center());
|
||||
if button.clicked() {
|
||||
tie = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
// One line between the status and Finish Sketch, and below it to the edge.
|
||||
ui.add_space((line - gap).max(0.0));
|
||||
// A row as tall as the button, so the gap below it is just the line.
|
||||
@@ -577,6 +601,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if look {
|
||||
crate::dialogs::look_at_sketch_again(app);
|
||||
}
|
||||
if tie && let Err(e) = app.run("sketch.loose_ends.tie", json!({})) {
|
||||
app.set_status(e, true);
|
||||
}
|
||||
if finish {
|
||||
app.finish_sketch();
|
||||
}
|
||||
@@ -748,6 +775,28 @@ mod tests {
|
||||
assert!(yaw.abs() < 0.01, "must align with the sketch's own x axis: yaw={yaw}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_counts_loose_ends_and_tie_closes_the_untied_ones() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
// A square of separate lines (closed by position only) and one lone line (two open ends).
|
||||
let c = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
for i in 0..4 {
|
||||
h.app.run("sketch.line", json!({"points": [c[i], c[(i + 1) % 4]], "infer": false, "connect": false})).unwrap();
|
||||
}
|
||||
h.app.run("sketch.line", json!({"points": [[20, 0], [30, 5]], "infer": false, "connect": false})).unwrap();
|
||||
h.settle();
|
||||
assert!(h.widget("Open 2").is_some(), "the status counts the open ends");
|
||||
assert!(h.widget("Untied 4").is_some(), "the status counts the untied ends");
|
||||
let at = crate::scenario::handle_at("sketch_palette:tie_ends").unwrap();
|
||||
h.call("ui.click", json!({"x": at.x, "y": at.y}));
|
||||
h.settle();
|
||||
assert!(h.widget("Untied 4").is_none(), "Tie constrains every untied end");
|
||||
assert!(h.widget("Open 2").is_some(), "open ends have nothing to tie to");
|
||||
let r = h.app.run("sketch.loose_ends", json!({})).unwrap();
|
||||
assert!(r["untied"].as_array().is_some_and(Vec::is_empty), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_disclosure_triangle_folds_and_unfolds_the_section() {
|
||||
OPTIONS_FOLDED.with(|v| v.set(false));
|
||||
|
||||
@@ -49,58 +49,145 @@ pub fn promoted(tab: &str, panel: &str) -> usize {
|
||||
/// How a panel is drawn on a toolbar that may be too narrow for every button.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Fit {
|
||||
/// Every button of the panel.
|
||||
Full,
|
||||
/// Its first buttons (all of them, or the ones that fit); the panel's drop-down lists every
|
||||
/// command.
|
||||
Buttons,
|
||||
/// One button that opens the panel's drop-down (which lists every command, pinned or not).
|
||||
Collapsed,
|
||||
/// In the » menu at the end of the toolbar.
|
||||
More,
|
||||
}
|
||||
|
||||
/// A panel's width with `buttons` buttons.
|
||||
pub fn panel_width(buttons: usize) -> f32 {
|
||||
(buttons.max(1) as f32 * 40.0).max(64.0) + 8.0
|
||||
/// One panel as [`fit`] sees it: how many buttons it has and whether they carry a label under the
|
||||
/// icon (Sketch > Create).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PanelSize {
|
||||
pub buttons: usize,
|
||||
pub labelled: bool,
|
||||
}
|
||||
|
||||
/// A collapsed panel's width.
|
||||
pub const COLLAPSED_WIDTH: f32 = 72.0;
|
||||
/// Where [`fit`] put a panel: how many of its buttons it draws, at which stride, and whether
|
||||
/// their labels show.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Plan {
|
||||
pub fit: Fit,
|
||||
/// Buttons drawn (the first ones of the panel).
|
||||
pub shown: usize,
|
||||
/// Distance from one button to the next.
|
||||
pub stride: f32,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// The panel's width on the toolbar (0 in the » menu).
|
||||
pub fn width(&self) -> f32 {
|
||||
match self.fit {
|
||||
Fit::More => 0.0,
|
||||
Fit::Collapsed => COLLAPSED_WIDTH,
|
||||
Fit::Buttons => (self.shown.max(1) as f32 * self.stride).max(PANEL_MIN) + 8.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether labelled buttons show their label (shortened below [`LABELLED_STRIDE`]).
|
||||
pub fn labels(&self) -> bool {
|
||||
self.stride >= LABEL_MIN_STRIDE
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel's width with `buttons` buttons.
|
||||
pub fn panel_width(buttons: usize) -> f32 {
|
||||
(buttons.max(1) as f32 * STRIDE).max(PANEL_MIN) + 8.0
|
||||
}
|
||||
|
||||
/// An icon button's stride.
|
||||
pub const STRIDE: f32 = 40.0;
|
||||
/// A labelled button's stride with its whole label (Sketch > Create).
|
||||
pub const LABELLED_STRIDE: f32 = 68.0;
|
||||
/// Below this stride a labelled button drops its label and shows just the icon.
|
||||
pub const LABEL_MIN_STRIDE: f32 = 50.0;
|
||||
/// The narrowest a panel gets (beside its 8 px margins), so its label stays readable.
|
||||
const PANEL_MIN: f32 = 64.0;
|
||||
|
||||
/// A collapsed panel's width: one button's panel.
|
||||
pub const COLLAPSED_WIDTH: f32 = PANEL_MIN + 8.0;
|
||||
|
||||
/// The gap after each panel (its separator).
|
||||
const PANEL_GAP: f32 = 2.0;
|
||||
|
||||
/// Fit panels of these full widths into `room`: all of them in full when they fit; otherwise
|
||||
/// panels collapse into their drop-down from the right, then (all collapsed and still too wide)
|
||||
/// the last ones go into the » menu. Nothing is dropped.
|
||||
pub fn fit(widths: &[f32], room: f32) -> Vec<Fit> {
|
||||
let mut fits = vec![Fit::Full; widths.len()];
|
||||
let total = |fits: &[Fit]| -> f32 {
|
||||
widths
|
||||
.iter()
|
||||
.zip(fits)
|
||||
.map(|(w, f)| match f {
|
||||
Fit::Full => w + PANEL_GAP,
|
||||
Fit::Collapsed => COLLAPSED_WIDTH.min(*w) + PANEL_GAP,
|
||||
Fit::More => 0.0,
|
||||
})
|
||||
.sum()
|
||||
/// The » button's room at the end of the panels, kept only while some panel is in it.
|
||||
pub const MORE_ROOM: f32 = 40.0;
|
||||
|
||||
/// Fit panels into `room`, giving up as little as it takes, one small step at a time:
|
||||
/// 1. labelled buttons narrow, their labels shortening and then going (icons only);
|
||||
/// 2. panels hand their last buttons to their drop-down one at a time, the panel showing the
|
||||
/// most buttons first (the rightmost of equals), down to one button (collapsed);
|
||||
/// 3. the last panels go into the » menu, and any room that frees goes back to buttons.
|
||||
///
|
||||
/// Every step frees at most one button's width, so the toolbar never leaves more than a button
|
||||
/// of blank space while something is hidden. The result depends only on `room` (no state from
|
||||
/// earlier frames), so it never flickers. Nothing is dropped.
|
||||
pub fn fit(panels: &[PanelSize], room: f32) -> Vec<Plan> {
|
||||
let room = if room.is_finite() { room } else { 0.0 };
|
||||
if let Some(plans) = shrink(panels, room) {
|
||||
return plans;
|
||||
}
|
||||
// 3. The last panels into the » menu (which takes its own room), as few as it takes; the
|
||||
// ones left keep as many buttons as fit.
|
||||
for kept in (0..panels.len()).rev() {
|
||||
if let Some(mut plans) = shrink(panels.get(..kept).unwrap_or_default(), room - MORE_ROOM) {
|
||||
plans.extend(panels.iter().skip(kept).map(|_| Plan { fit: Fit::More, shown: 0, stride: STRIDE }));
|
||||
return plans;
|
||||
}
|
||||
}
|
||||
panels.iter().map(|_| Plan { fit: Fit::More, shown: 0, stride: STRIDE }).collect()
|
||||
}
|
||||
|
||||
/// The room plans take on the toolbar (each panel and its separator).
|
||||
fn used(plans: &[Plan]) -> f32 {
|
||||
plans.iter().filter(|p| p.fit != Fit::More).map(|p| p.width() + PANEL_GAP).sum()
|
||||
}
|
||||
|
||||
/// Steps 1 and 2 of [`fit`]: every panel on the toolbar, or None when even one button each
|
||||
/// doesn't fit.
|
||||
fn shrink(panels: &[PanelSize], room: f32) -> Option<Vec<Plan>> {
|
||||
let mut plans: Vec<Plan> =
|
||||
panels.iter().map(|p| Plan { fit: Fit::Buttons, shown: p.buttons, stride: if p.labelled { LABELLED_STRIDE } else { STRIDE } }).collect();
|
||||
if used(&plans) <= room {
|
||||
return Some(plans);
|
||||
}
|
||||
// 1. Labelled buttons narrow together, to the widest whole-pixel stride that fits (icons
|
||||
// only at worst).
|
||||
let narrow = |plans: &mut [Plan], stride: f32| {
|
||||
for (p, s) in plans.iter_mut().zip(panels) {
|
||||
if s.labelled {
|
||||
p.stride = stride;
|
||||
}
|
||||
}
|
||||
};
|
||||
for i in (0..widths.len()).rev() {
|
||||
if total(&fits) <= room {
|
||||
return fits;
|
||||
}
|
||||
if let Some(f) = fits.get_mut(i) {
|
||||
*f = Fit::Collapsed;
|
||||
let mut stride = LABELLED_STRIDE;
|
||||
while stride > STRIDE {
|
||||
stride -= 1.0;
|
||||
narrow(&mut plans, stride);
|
||||
if used(&plans) <= room {
|
||||
return Some(plans);
|
||||
}
|
||||
}
|
||||
for i in (0..widths.len()).rev() {
|
||||
if total(&fits) <= room {
|
||||
return fits;
|
||||
}
|
||||
if let Some(f) = fits.get_mut(i) {
|
||||
*f = Fit::More;
|
||||
// 2. One button at a time into the drop-downs: from the panel showing the most (the
|
||||
// rightmost of equals), down to one button each.
|
||||
while used(&plans) > room {
|
||||
let pick = plans
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.fit == Fit::Buttons && p.shown > 1)
|
||||
.max_by(|(i, a), (j, b)| a.shown.cmp(&b.shown).then(i.cmp(j)));
|
||||
let (i, _) = pick?;
|
||||
if let Some(p) = plans.get_mut(i) {
|
||||
p.shown -= 1;
|
||||
if p.shown == 1 {
|
||||
p.fit = Fit::Collapsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
fits
|
||||
Some(plans)
|
||||
}
|
||||
|
||||
/// The commands a panel's drop-down (or its » submenu) lists: its own, then any button moved
|
||||
@@ -607,6 +694,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let tr = Rect::from_min_size(pos2(x, tabs_y), vec2(w, 22.0));
|
||||
let active = app.ui.tab == tab;
|
||||
let resp = ui.interact(tr, ui.id().with(("tab", tab)), Sense::click());
|
||||
crate::scenario::publish_rect(&format!("toolbar_tab:{tab}"), tr);
|
||||
if active {
|
||||
let c = if tab == "SKETCH" { t.sketch_accent } else { t.accent };
|
||||
painter.line_segment([pos2(tr.left() + 4.0, tr.bottom() - 1.0), pos2(tr.right() - 4.0, tr.bottom() - 1.0)], Stroke::new(2.0, c));
|
||||
@@ -654,33 +742,39 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
(panel, cmds, shown)
|
||||
})
|
||||
.collect();
|
||||
let width_of = |panel: &str, count: usize| {
|
||||
if tab == "SKETCH" && panel == "CREATE" { (count.max(1) as f32 * 68.0).max(64.0) + 8.0 } else { panel_width(count) }
|
||||
};
|
||||
let widths: Vec<f32> = lists.iter().map(|(panel, _, shown)| width_of(panel, shown.len())).collect();
|
||||
let sizes: Vec<PanelSize> =
|
||||
lists.iter().map(|(panel, _, shown)| PanelSize { buttons: shown.len(), labelled: tab == "SKETCH" && *panel == "CREATE" }).collect();
|
||||
// The AI button keeps the toolbar row's right end, whatever the ribbon's width
|
||||
// (docs/agent-chat.md); panels that no longer fit go to the » menu as usual.
|
||||
let right = if crate::agent_chat::toolbar_button_shown(app) { r.right() - AI_BUTTON_ROOM } else { r.right() };
|
||||
let fits = fit(&widths, right - 40.0 - px);
|
||||
crate::scenario::publish_count("toolbar_collapsed", fits.iter().filter(|f| **f == Fit::Collapsed).count() as f64);
|
||||
crate::scenario::publish_count("toolbar_more", fits.iter().filter(|f| **f == Fit::More).count() as f64);
|
||||
for ((panel, cmds, shown), fit) in lists.into_iter().zip(fits) {
|
||||
let plans = fit(&sizes, right - 4.0 - px);
|
||||
crate::scenario::publish_count("toolbar_collapsed", plans.iter().filter(|p| p.fit == Fit::Collapsed).count() as f64);
|
||||
crate::scenario::publish_count("toolbar_more", plans.iter().filter(|p| p.fit == Fit::More).count() as f64);
|
||||
crate::scenario::publish_count(
|
||||
"toolbar_trimmed",
|
||||
plans.iter().zip(&sizes).filter(|(p, s)| p.fit == Fit::Buttons && p.shown < s.buttons).count() as f64,
|
||||
);
|
||||
for ((panel, cmds, shown), plan) in lists.into_iter().zip(plans) {
|
||||
let panel: &&str = &panel;
|
||||
if fit == Fit::More {
|
||||
if plan.fit == Fit::More {
|
||||
overflow.push((*panel, menu_commands(&cmds, &shown)));
|
||||
continue;
|
||||
}
|
||||
let collapsed = fit == Fit::Collapsed;
|
||||
let width = if collapsed { COLLAPSED_WIDTH.min(width_of(panel, shown.len())) } else { width_of(panel, shown.len()) };
|
||||
let collapsed = plan.fit == Fit::Collapsed;
|
||||
let width = plan.width();
|
||||
let sketch_create = tab == "SKETCH" && *panel == "CREATE";
|
||||
let stride = if sketch_create { 68.0 } else { 40.0 };
|
||||
let stride = plan.stride;
|
||||
// Sketch > Create's buttons carry their label while there's room for one.
|
||||
let labelled = sketch_create && plan.labels();
|
||||
// Buttons that went into the drop-down for want of room are still listed there.
|
||||
let trimmed = plan.shown < shown.len();
|
||||
let enabled_panel = !cmds.is_empty() || *panel == "SELECT";
|
||||
// Per tab: panels of the same name on other tabs (MODIFY, CREATE…) list other commands,
|
||||
// and the drop-down's area remembers its size by this id (#874).
|
||||
let popup_id = ui.id().with(("panel_menu", &tab, panel));
|
||||
// Dropped on the panel's free space (all of a collapsed panel): the button goes to
|
||||
// its end.
|
||||
let drawn = if collapsed { 0 } else { shown.len() };
|
||||
let drawn = if collapsed { 0 } else { plan.shown.min(shown.len()) };
|
||||
// Buttons narrower than the panel's minimum width (a one-button panel) are centred.
|
||||
let inset = if drawn == 0 { 0.0 } else { ((width - 8.0 - (drawn as f32 * stride - 2.0)) / 2.0).max(0.0).floor() };
|
||||
let bx = px + 4.0 + inset;
|
||||
@@ -753,8 +847,13 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
"sketch.finish" => "finish",
|
||||
_ => selected.icon,
|
||||
};
|
||||
let icon_rect =
|
||||
if sketch_create { Rect::from_center_size(pos2(br.center().x, br.top() + 13.0), vec2(24.0, 24.0)) } else { br.shrink(6.0) };
|
||||
let icon_rect = if labelled {
|
||||
Rect::from_center_size(pos2(br.center().x, br.top() + 13.0), vec2(24.0, 24.0))
|
||||
} else if sketch_create {
|
||||
Rect::from_center_size(br.center(), vec2(24.0, 24.0))
|
||||
} else {
|
||||
br.shrink(6.0)
|
||||
};
|
||||
icons::paint(&painter, icon_rect, icon, ink, fill, acc);
|
||||
let label = match c.id {
|
||||
"parameters.change" if *panel == "CONFIGURE" => "Parameters",
|
||||
@@ -778,22 +877,22 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if arrow.hovered() {
|
||||
painter.rect_filled(ar, 3.0, t.hover);
|
||||
}
|
||||
painter.text(ar.center() - vec2(4.0, 0.0), Align2::CENTER_CENTER, app.tr_label(f.label), FontId::proportional(10.0), t.text);
|
||||
caret(&painter, pos2(ar.right() - 5.0, ar.center().y), t.text);
|
||||
if labelled {
|
||||
let g = short_label(&painter, app.tr_label(f.label), ar.width() - 12.0, t.text);
|
||||
painter.galley(ar.center() - vec2(4.0 + g.size().x / 2.0, g.size().y / 2.0), g, t.text);
|
||||
caret(&painter, pos2(ar.right() - 5.0, ar.center().y), t.text);
|
||||
} else {
|
||||
caret(&painter, ar.center(), t.text);
|
||||
}
|
||||
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{} variants", f.label)));
|
||||
egui::Popup::menu(&arrow).show(|ui| {
|
||||
let variants: Vec<_> = f.commands.iter().filter_map(|id| solidcraft_engine::find_command(id)).collect();
|
||||
dropdown_keys(app, ui, &variants);
|
||||
sketch_menu_rows(app, ui, &variants, &mut menu, &mut toggle);
|
||||
});
|
||||
} else if sketch_create {
|
||||
painter.text(
|
||||
pos2(br.center().x, br.bottom() - 5.0),
|
||||
Align2::CENTER_CENTER,
|
||||
app.tr_label(c.label),
|
||||
FontId::proportional(10.0),
|
||||
t.text,
|
||||
);
|
||||
} else if labelled {
|
||||
let g = short_label(&painter, app.tr_label(c.label), br.width() - 4.0, t.text);
|
||||
painter.galley(pos2(br.center().x - g.size().x / 2.0, br.bottom() - 5.0 - g.size().y / 2.0), g, t.text);
|
||||
}
|
||||
}
|
||||
if shown.is_empty() && !collapsed {
|
||||
@@ -844,6 +943,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// Labels, gaps and margins append; actual buttons keep their insert-before target.
|
||||
// Respect the current layer so a dropdown covering the panel is not a drop target.
|
||||
let panel_rect = Rect::from_min_max(pos2(px, top + 2.0), pos2(px + width, r.bottom()));
|
||||
crate::scenario::publish_rect(&format!("panel:{tab}:{panel}"), panel_rect);
|
||||
let over_button = ui.input(|input| input.pointer.hover_pos()).is_some_and(|p| {
|
||||
(0..drawn).any(|i| Rect::from_min_size(pos2(bx + i as f32 * stride, top + 2.0), vec2(stride - 2.0, 38.0)).contains(p))
|
||||
});
|
||||
@@ -857,7 +957,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let open = ui.ctx().data(|d| d.get_temp::<egui::Id>(egui::Id::new("sc_panel_open"))) == Some(popup_id);
|
||||
if open {
|
||||
let cmds = if collapsed { menu_commands(&cmds, &shown) } else { cmds.clone() };
|
||||
let cmds = if collapsed || trimmed { menu_commands(&cmds, &shown) } else { cmds.clone() };
|
||||
let area = crate::frame::area(popup_id).fixed_pos(lr.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
@@ -950,6 +1050,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
crate::scenario::publish_handle("toolbar:overflow", response.response.rect.center());
|
||||
crate::scenario::publish_rect("toolbar:overflow", response.response.rect);
|
||||
response.response.on_hover_text(crate::i18n::tr_label("More toolbar panels"));
|
||||
});
|
||||
}
|
||||
@@ -985,6 +1086,13 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A button's label on one line in `max_width`, shortened with an ellipsis when it doesn't fit.
|
||||
fn short_label(painter: &egui::Painter, text: &str, max_width: f32, color: Color32) -> std::sync::Arc<egui::Galley> {
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(text.to_string(), FontId::proportional(10.0), color);
|
||||
job.wrap = egui::text::TextWrapping { max_width: max_width.max(8.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
|
||||
painter.layout_job(job)
|
||||
}
|
||||
|
||||
/// Room the AI button takes at the toolbar row's right end.
|
||||
const AI_BUTTON_ROOM: f32 = 52.0;
|
||||
|
||||
@@ -993,6 +1101,7 @@ fn ai_button(app: &mut SolveApp, ui: &mut egui::Ui, at: Rect) {
|
||||
let t = Tokens::get();
|
||||
let resp = ui.interact(at, ui.id().with("ai_button"), Sense::click());
|
||||
crate::scenario::publish_handle("toolbar:ai", at.center());
|
||||
crate::scenario::publish_rect("toolbar:ai", at);
|
||||
let open = app.agent_chat.open;
|
||||
let painter = ui.painter();
|
||||
painter.line_segment([pos2(at.left() - 4.0, at.top() + 4.0), pos2(at.left() - 4.0, at.bottom() - 4.0)], Stroke::new(1.0, t.border));
|
||||
@@ -1372,25 +1481,84 @@ mod header_report_tests {
|
||||
assert!(!solidcraft_engine::io::is_design_container(&std::fs::read(saved).unwrap()), "subsequent Save keeps a .json choice");
|
||||
}
|
||||
|
||||
fn sizes(buttons: &[usize]) -> Vec<PanelSize> {
|
||||
buttons.iter().map(|&b| PanelSize { buttons: b, labelled: false }).collect()
|
||||
}
|
||||
|
||||
fn used_with_more(plans: &[Plan]) -> f32 {
|
||||
used(plans) + if plans.iter().any(|p| p.fit == Fit::More) { MORE_ROOM } else { 0.0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panels_fit_collapse_then_move_to_more_and_none_is_dropped() {
|
||||
fn panels_give_up_one_button_at_a_time_then_move_to_more_and_none_is_dropped() {
|
||||
// A panel with 14 buttons and three default ones.
|
||||
let widths = [panel_width(14), panel_width(4), panel_width(2), panel_width(2)];
|
||||
let full: f32 = widths.iter().map(|w| w + 2.0).sum();
|
||||
assert_eq!(fit(&widths, full), vec![Fit::Full; 4]);
|
||||
// A little short: the last panel collapses first.
|
||||
assert_eq!(fit(&widths, full - 10.0), vec![Fit::Full, Fit::Full, Fit::Full, Fit::Collapsed]);
|
||||
// Every panel collapsed fits.
|
||||
let collapsed = 4.0 * (COLLAPSED_WIDTH + 2.0);
|
||||
assert_eq!(fit(&widths, collapsed), vec![Fit::Collapsed; 4]);
|
||||
// Narrower: the last ones go into the » menu; then all of them.
|
||||
assert_eq!(fit(&widths, collapsed - 1.0), vec![Fit::Collapsed, Fit::Collapsed, Fit::Collapsed, Fit::More]);
|
||||
assert_eq!(fit(&widths, 0.0), vec![Fit::More; 4]);
|
||||
assert_eq!(fit(&widths, f32::NAN).len(), 4);
|
||||
let panels = sizes(&[14, 4, 2, 2]);
|
||||
let full: f32 = [14, 4, 2, 2].iter().map(|&b| panel_width(b) + PANEL_GAP).sum();
|
||||
let all = |plans: &[Plan]| plans.iter().map(|p| (p.fit, p.shown)).collect::<Vec<_>>();
|
||||
assert_eq!(all(&fit(&panels, full)), [(Fit::Buttons, 14), (Fit::Buttons, 4), (Fit::Buttons, 2), (Fit::Buttons, 2)]);
|
||||
// A little short: the widest panel hands one button to its drop-down; the others keep all theirs.
|
||||
assert_eq!(all(&fit(&panels, full - 10.0)), [(Fit::Buttons, 13), (Fit::Buttons, 4), (Fit::Buttons, 2), (Fit::Buttons, 2)]);
|
||||
// Every panel down to one button (collapsed) fits.
|
||||
let collapsed = 4.0 * (COLLAPSED_WIDTH + PANEL_GAP);
|
||||
assert_eq!(all(&fit(&panels, collapsed)), [(Fit::Collapsed, 1); 4]);
|
||||
// Narrower: the last one goes into the » menu, and the room it frees goes back to buttons.
|
||||
let more = fit(&panels, collapsed - 1.0);
|
||||
assert_eq!(more.iter().map(|p| p.fit).collect::<Vec<_>>(), [Fit::Buttons, Fit::Buttons, Fit::Collapsed, Fit::More]);
|
||||
assert!(more.iter().take(3).all(|p| p.shown >= 1));
|
||||
// Then all of them.
|
||||
assert!(fit(&panels, 0.0).iter().all(|p| p.fit == Fit::More));
|
||||
assert_eq!(fit(&panels, f32::NAN).len(), 4);
|
||||
// No cap: 40 px a button however many there are.
|
||||
assert_eq!(panel_width(30) - panel_width(20), 400.0);
|
||||
}
|
||||
|
||||
/// #2108: whatever the room, the toolbar uses it. It never leaves more than one button's width
|
||||
/// blank while something is hidden, never runs past the room, and shrinks steadily as the
|
||||
/// room does (so a few pixels never flip it back and forth).
|
||||
#[test]
|
||||
fn panels_fill_the_room_they_have_at_every_width() {
|
||||
let layouts = [
|
||||
// SOLID: CREATE, MODIFY, ASSEMBLE, CONFIGURE, CONSTRUCT, INSPECT, INSERT, SELECT.
|
||||
vec![(4, false), (4, false), (3, false), (2, false), (2, false), (2, false), (2, false), (0, false)],
|
||||
// SKETCH: labelled CREATE, then MODIFY … FINISH SKETCH.
|
||||
vec![(8, true), (2, false), (3, false), (6, false), (2, false), (2, false), (0, false), (1, false)],
|
||||
// Many pins on one panel.
|
||||
vec![(14, false), (4, false), (2, false), (2, false)],
|
||||
];
|
||||
for layout in layouts {
|
||||
let panels: Vec<PanelSize> = layout.iter().map(|&(buttons, labelled)| PanelSize { buttons, labelled }).collect();
|
||||
let mut last: Option<(f32, usize)> = None;
|
||||
for room in (0..=2000).rev().map(|r| r as f32) {
|
||||
let plans = fit(&panels, room);
|
||||
let width = used_with_more(&plans);
|
||||
let hidden = plans
|
||||
.iter()
|
||||
.zip(&panels)
|
||||
.any(|(p, s)| p.fit != Fit::Buttons || p.shown < s.buttons || p.stride < if s.labelled { LABELLED_STRIDE } else { STRIDE });
|
||||
let more = plans.iter().filter(|p| p.fit == Fit::More).count();
|
||||
if more < panels.len() {
|
||||
assert!(width <= room + 0.01, "{layout:?} at {room}: {width} overruns");
|
||||
}
|
||||
// One button: an icon's stride, or a collapsed panel's when the next panel waits in the » menu.
|
||||
let button = if more > 0 { COLLAPSED_WIDTH + PANEL_GAP } else { STRIDE };
|
||||
if hidden && more < panels.len() {
|
||||
assert!(room - width < button, "{layout:?} at {room}: {} px blank while something is hidden", room - width);
|
||||
}
|
||||
// Fewer pixels never bring a panel back out of the » menu.
|
||||
if let Some((_, prev_more)) = last {
|
||||
assert!(more >= prev_more, "{layout:?} at {room}: a panel came back out of the » menu");
|
||||
}
|
||||
last = Some((width, more));
|
||||
// A panel shows its icons: one at the very least.
|
||||
for (p, s) in plans.iter().zip(&panels) {
|
||||
if p.fit != Fit::More && s.buttons > 0 {
|
||||
assert!(p.shown >= 1, "{layout:?} at {room}: a panel without icons");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_frame(h: &mut crate::scenario::Harness) {
|
||||
let token = h.app.shot_token;
|
||||
crate::issue_report::receive(&mut h.app, token, std::sync::Arc::new(egui::ColorImage::new([2, 2], vec![Color32::GREEN; 4])));
|
||||
@@ -1788,3 +1956,84 @@ mod dropdown_toggle_tests {
|
||||
assert!(rect_of("toolbar_dropdown").is_none(), "the second click closes it");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fill_width_tests {
|
||||
use super::*;
|
||||
|
||||
/// #2108: at every common window width, in the Solid and the Sketch tab, the toolbar uses the
|
||||
/// width it has. No blank run wider than one button while something is hidden, every panel
|
||||
/// on the toolbar shows its icons, the » menu only when even one button a panel won't fit,
|
||||
/// and neither the tab row nor the title bar's own controls run into each other.
|
||||
#[test]
|
||||
fn toolbar_fills_its_width_from_wide_to_narrow_windows() {
|
||||
for sketch in [false, true] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.custom_titlebar = true;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
if sketch {
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
}
|
||||
let tab = if sketch { "SKETCH" } else { "SOLID" };
|
||||
h.app.ui.tab = tab.into();
|
||||
for w in [1920.0, 1600.0, 1440.0, 1366.0, 1280.0, 1100.0, 900.0, 700.0] {
|
||||
h.call("ui.resize", json!({"width":w,"height":900}));
|
||||
h.frames(3);
|
||||
let at = format!("{tab} at {w}");
|
||||
let bar = crate::scenario::rect_of("toolbar").unwrap();
|
||||
let ai = crate::scenario::rect_of("toolbar:ai");
|
||||
let right = ai.map_or(bar.right(), |a| a.left() - 4.0);
|
||||
let count = |name: &str| crate::scenario::count_of(name).unwrap_or(-1.0);
|
||||
let (collapsed, trimmed, more) = (count("toolbar_collapsed"), count("toolbar_trimmed"), count("toolbar_more"));
|
||||
let lists = crate::toolbar_custom::panel_lists(tab);
|
||||
let mut end = bar.left();
|
||||
let mut on_bar = 0;
|
||||
for (panel, cmds, promote) in &lists {
|
||||
let Some(rect) = crate::scenario::rect_of(&format!("panel:{tab}:{panel}")) else { continue };
|
||||
on_bar += 1;
|
||||
end = end.max(rect.right() + PANEL_GAP);
|
||||
if crate::toolbar_custom::buttons(&h.app, tab, panel, cmds, *promote).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let icons = crate::scenario::handles("button:").iter().filter(|(_, p)| rect.contains(*p)).count()
|
||||
+ usize::from(crate::scenario::handle_at(&format!("collapsed:{tab}:{panel}")).is_some());
|
||||
assert!(icons >= 1, "{at}: {panel} shows no icons");
|
||||
}
|
||||
assert_eq!(on_bar + more as usize, lists.len(), "{at}: every panel is on the toolbar or in the » menu");
|
||||
let overflow = crate::scenario::rect_of("toolbar:overflow");
|
||||
assert_eq!(overflow.is_some(), more > 0.0, "{at}: the » menu shows exactly when a panel is in it");
|
||||
let gap = right - end - if more > 0.0 { MORE_ROOM } else { 0.0 };
|
||||
assert!(gap >= -0.5, "{at}: the panels run {} px past the room", -gap);
|
||||
let hidden = collapsed + trimmed + more > 0.0;
|
||||
let button = if more > 0.0 { COLLAPSED_WIDTH + PANEL_GAP } else { STRIDE };
|
||||
if hidden {
|
||||
assert!(gap < button, "{at}: {gap} px left blank while buttons are hidden");
|
||||
}
|
||||
// The » menu only when one button a panel can't fit.
|
||||
if w >= 900.0 {
|
||||
assert_eq!(more, 0.0, "{at}: the » menu while there is room");
|
||||
}
|
||||
if w >= 1600.0 {
|
||||
assert!(!hidden, "{at}: buttons hidden on a wide window");
|
||||
}
|
||||
if let Some(o) = overflow {
|
||||
assert!(o.right() <= right + 4.0, "{at}: the » button under the AI button");
|
||||
}
|
||||
// The tab row stops short of the AI button.
|
||||
for t in TABS.iter().chain(["SKETCH"].iter()) {
|
||||
if let (Some(r), Some(ai)) = (crate::scenario::rect_of(&format!("toolbar_tab:{t}")), ai) {
|
||||
assert!(r.right() < ai.left(), "{at}: the {t} tab runs into the AI button");
|
||||
}
|
||||
}
|
||||
// The title bar: its document tabs end before Report a bug, Help and Preferences.
|
||||
let report = crate::scenario::rect_of("titlebar:report").unwrap();
|
||||
let tabs_end = crate::scenario::handles("tab:").iter().map(|(_, p)| p.x).fold(0.0, f32::max);
|
||||
assert!(tabs_end < report.left(), "{at}: the document tabs run into Report a bug");
|
||||
let help = crate::scenario::handle_at("appbar:help").unwrap();
|
||||
let prefs = crate::scenario::handle_at("titlebar:preferences").unwrap();
|
||||
assert!(report.right() < help.x && help.x < prefs.x, "{at}: the title bar's right-hand controls overlap");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::geom::{Vec2, Vec3};
|
||||
use solidcraft_engine::render::{Camera, Mat4, Rgb};
|
||||
use solidcraft_engine::sketch::CurveKind;
|
||||
use solidcraft_engine::view::{colors, grid_plane, grid_step, sketch_lines_with_sampler};
|
||||
use solidcraft_engine::view::{GridPlane, colors, grid_plane, grid_step, sketch_lines_with_sampler};
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use crate::gpu::{Frame, GpuScene, GpuTarget, SceneSlot, ViewportCallback};
|
||||
@@ -130,6 +130,9 @@ pub struct ViewportState {
|
||||
pub(crate) text_drag: Option<(u64, String)>,
|
||||
/// A part that joints place, being dragged.
|
||||
pub(crate) joint_drag: Option<crate::joint_drag::Drag>,
|
||||
/// The plane the grid was last drawn on while Preferences ▸ Grid follows the view is on
|
||||
/// (#1470), kept so the choice is hysteretic near the thresholds (#1993).
|
||||
pub(crate) grid_plane: std::sync::Mutex<GridPlane>,
|
||||
}
|
||||
|
||||
/// Something under the cursor.
|
||||
@@ -316,6 +319,9 @@ fn scene_key(app: &SolveApp) -> u64 {
|
||||
fn sketch_scene_key(app: &SolveApp, proj: &Proj) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
scene_key(app).hash(&mut h);
|
||||
// The grid this scene draws follows the view when the preference says so (#1470): turning it
|
||||
// on or off redraws even while the camera holds still.
|
||||
app.preferences.grid_follows_view.hash(&mut h);
|
||||
(std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h);
|
||||
crate::sketch_display::projection_key(proj, app.viewport.pixels_per_point()).hash(&mut h);
|
||||
h.finish()
|
||||
@@ -733,8 +739,16 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene {
|
||||
// The ground plane: XY with Z up, XZ with Y up (laid out in the camera's Z-up frame), or
|
||||
// the upright origin plane facing the view when the ground is seen edge-on.
|
||||
let tf = app.cam.to_frame(app.cam.target);
|
||||
// On the ground (XY) unless the Grid follows the view preference is on (#1470).
|
||||
let (u, v) = if app.preferences.grid_follows_view { grid_plane(app.cam.to_frame(app.cam.back())) } else { (Vec3::X, Vec3::Y) };
|
||||
// On the ground (XY) unless the Grid follows the view preference is on (#1470); then on
|
||||
// the plane the last frame drew, so the choice is hysteretic near the thresholds (#1993).
|
||||
let (u, v) = if app.preferences.grid_follows_view {
|
||||
app.viewport.grid_plane.lock().map_or((Vec3::X, Vec3::Y), |mut last| {
|
||||
*last = grid_plane(app.cam.to_frame(app.cam.back()), *last);
|
||||
last.axes()
|
||||
})
|
||||
} else {
|
||||
(Vec3::X, Vec3::Y)
|
||||
};
|
||||
let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major);
|
||||
let c = snap(u) + snap(v);
|
||||
let w3 = |p: Vec3| app.cam.from_frame(p).to_f32();
|
||||
@@ -2447,7 +2461,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::tools::on_click(app, &proj, a);
|
||||
app.viewport.draw_drag = app.tool.as_ref().is_some_and(|t| t.pts.len() == 1);
|
||||
}
|
||||
if resp.clicked() && !held {
|
||||
let reference_pick = (resp.clicked() || resp.double_clicked())
|
||||
&& !held
|
||||
&& app.dialog.is_none()
|
||||
&& crate::dim_view::pick_reference(ui.ctx(), p, resp.double_clicked());
|
||||
if resp.clicked() && !held && !reference_pick {
|
||||
if app.tool.is_some() {
|
||||
crate::tools::on_click(app, &proj, p);
|
||||
} else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
|
||||
@@ -2494,6 +2512,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// Double-click a dimension: edit its value in place.
|
||||
let dim_edit = resp.double_clicked()
|
||||
&& !held
|
||||
&& !crate::dim_view::editing()
|
||||
&& app.tool.is_none()
|
||||
&& app.dialog.is_none()
|
||||
&& (crate::sketch_modify_ui::double_click_pattern(app, p) || crate::dim_view::double_click(app, p));
|
||||
@@ -3158,6 +3177,7 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c);
|
||||
}
|
||||
}
|
||||
loose_end_markers(sk, &ss.ends, &ss.plane, painter, proj, &t);
|
||||
// Sketch status.
|
||||
if let Some(r) = app.viewport.rect {
|
||||
let msg = if !ss.report.ok() {
|
||||
@@ -3172,6 +3192,28 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a profile can fail: a ring round each open end (it touches no other curve) and a
|
||||
/// diamond round each untied one (it touches another curve only by position), told apart by
|
||||
/// shape. Both are warnings, not errors: an open sketch (a sweep path, an axis) is valid.
|
||||
fn loose_end_markers(
|
||||
sk: &solidcraft_engine::sketch::Sketch,
|
||||
ends: &solidcraft_engine::sketch::EndReport,
|
||||
plane: &solidcraft_engine::geom::Plane,
|
||||
painter: &egui::Painter,
|
||||
proj: &Proj,
|
||||
t: &Tokens,
|
||||
) {
|
||||
let screen = |i: usize| sk.point(i).and_then(|p| proj.to_screen(plane.to_world(p)));
|
||||
for sp in ends.open.iter().filter_map(|&i| screen(i)) {
|
||||
painter.circle_stroke(sp, 7.0, Stroke::new(1.8, t.warning));
|
||||
}
|
||||
for sp in ends.untied.iter().filter_map(|u| screen(u.point)) {
|
||||
let r = 7.5;
|
||||
let diamond = vec![pos2(sp.x, sp.y - r), pos2(sp.x + r, sp.y), pos2(sp.x, sp.y + r), pos2(sp.x - r, sp.y)];
|
||||
painter.add(egui::Shape::closed_line(diamond, Stroke::new(1.8, t.warning)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The colours of a headless picture of the model (`ui.render`, the agent's view image) in the
|
||||
/// dark or the light theme and a colour vision mode, from the tokens the window draws with, so the
|
||||
/// picture matches the window. The light theme's tokens (Off) are the engine's own colours, so a light
|
||||
@@ -6056,10 +6098,16 @@ mod grid_plane_tests {
|
||||
fn plane(view: StandardView, up: UpAxis) -> (Vec3, Vec3) {
|
||||
let (yaw, pitch) = view.angles();
|
||||
let cam = Camera { yaw, pitch, up, ..Camera::default() };
|
||||
let (u, v) = grid_plane(cam.to_frame(cam.back()));
|
||||
let (u, v) = grid_plane(cam.to_frame(cam.back()), GridPlane::default()).axes();
|
||||
(cam.from_frame(u), cam.from_frame(v))
|
||||
}
|
||||
|
||||
/// The camera frame's direction toward the eye for a view at `yaw` and `pitch` degrees.
|
||||
fn back_at(yaw_deg: f64, pitch_deg: f64) -> Vec3 {
|
||||
let cam = Camera { yaw: yaw_deg.to_radians(), pitch: pitch_deg.to_radians(), ..Camera::default() };
|
||||
cam.to_frame(cam.back())
|
||||
}
|
||||
|
||||
/// #1470: the grid lies on the ground unless the view sees the ground edge-on; then it lies on
|
||||
/// the upright origin plane facing the view, Z up or Y up alike.
|
||||
#[test]
|
||||
@@ -6079,6 +6127,67 @@ mod grid_plane_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1993: the view has to turn clearly off the ground before the grid leaves it, and clearly
|
||||
/// away from a wall before it goes back, so a view wobbling at the threshold does not flicker.
|
||||
#[test]
|
||||
fn the_grid_plane_thresholds_have_hysteresis() {
|
||||
// 9°: edge-on enough to enter the wall.
|
||||
assert_eq!(grid_plane(back_at(0.0, 9.0), GridPlane::Ground), GridPlane::Xz);
|
||||
// 12°: past the entering limit, but not enough to leave a wall the grid is already on.
|
||||
assert_eq!(grid_plane(back_at(0.0, 12.0), GridPlane::Ground), GridPlane::Ground);
|
||||
assert_eq!(grid_plane(back_at(0.0, 12.0), GridPlane::Xz), GridPlane::Xz);
|
||||
// 16°: clearly off the ground, so even a wall goes back.
|
||||
assert_eq!(grid_plane(back_at(0.0, 16.0), GridPlane::Xz), GridPlane::Ground);
|
||||
// Below the ground the same, and a view straight down stays on the ground.
|
||||
assert_eq!(grid_plane(back_at(30.0, -12.0), GridPlane::Yz), GridPlane::Yz);
|
||||
assert_eq!(grid_plane(Vec3::new(0.0, 0.0, 1.0), GridPlane::Xz), GridPlane::Ground);
|
||||
assert_eq!(grid_plane(Vec3::new(0.0, 0.0, -1.0), GridPlane::Xz), GridPlane::Ground);
|
||||
}
|
||||
|
||||
/// #1993: orbiting at a low elevation keeps the wall until a side view is near; the other
|
||||
/// wall takes over only there, never at the 45° crossing.
|
||||
#[test]
|
||||
fn a_low_orbit_switches_walls_only_near_a_side_view() {
|
||||
let low = |yaw_deg: f64| back_at(yaw_deg, 3.0);
|
||||
// Entering from the ground still picks the wall facing the view.
|
||||
assert_eq!(grid_plane(low(44.0), GridPlane::Ground), GridPlane::Xz);
|
||||
assert_eq!(grid_plane(low(50.0), GridPlane::Ground), GridPlane::Yz);
|
||||
// A wall is kept across the crossing and only changes near the side view it faces.
|
||||
assert_eq!(grid_plane(low(44.0), GridPlane::Xz), GridPlane::Xz);
|
||||
assert_eq!(grid_plane(low(50.0), GridPlane::Xz), GridPlane::Xz, "no swap at the 45° crossing");
|
||||
assert_eq!(grid_plane(low(70.0), GridPlane::Xz), GridPlane::Xz);
|
||||
assert_eq!(grid_plane(low(80.0), GridPlane::Xz), GridPlane::Yz, "the Left view's wall takes over near it");
|
||||
assert_eq!(grid_plane(low(10.0), GridPlane::Yz), GridPlane::Xz, "the Front view's wall takes over near it");
|
||||
}
|
||||
|
||||
/// #1993: a hostile view direction (a design file, a control request) never panics and never
|
||||
/// leaves the grid off the ground's axes.
|
||||
#[test]
|
||||
fn a_hostile_view_direction_stays_on_the_ground() {
|
||||
for back in [Vec3::new(f64::NAN, 0.0, 0.0), Vec3::new(0.0, f64::NEG_INFINITY, 0.0), Vec3::new(0.0, 0.0, f64::INFINITY), Vec3::ZERO] {
|
||||
assert_eq!(grid_plane(back, GridPlane::Xz), GridPlane::Ground, "{back:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1993: the viewport keeps the plane it drew, so the next frame's choice is hysteretic.
|
||||
#[test]
|
||||
fn the_viewport_keeps_the_grid_plane_between_frames() {
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
app.preferences.grid_follows_view = true;
|
||||
app.cam.yaw = 0.0;
|
||||
app.cam.pitch = 0.0;
|
||||
let _ = build_sketch_scene(&app, &projection(&app, rect));
|
||||
assert_eq!(*app.viewport.grid_plane.lock().unwrap(), GridPlane::Xz, "the Front view takes the wall");
|
||||
// Wobble past the entering limit: the wall stays (before #1993 it flipped to the ground).
|
||||
app.cam.pitch = 0.23;
|
||||
let _ = build_sketch_scene(&app, &projection(&app, rect));
|
||||
assert_eq!(*app.viewport.grid_plane.lock().unwrap(), GridPlane::Xz, "a wobble does not flicker");
|
||||
app.cam.pitch = 0.3;
|
||||
let _ = build_sketch_scene(&app, &projection(&app, rect));
|
||||
assert_eq!(*app.viewport.grid_plane.lock().unwrap(), GridPlane::Ground, "a clear turn off the ground returns");
|
||||
}
|
||||
|
||||
/// A sketch is drawn in a parallel view even when the preference asks for perspective; the
|
||||
/// preference comes back when the sketch is finished.
|
||||
#[test]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{"click": {"handle": "titlebar:preferences"}},
|
||||
{"expect": {"prefs": {"dialog": "General", "color_vision": "off"}, "same_width": "prefs:window"}},
|
||||
{"widget": "Graphics"},
|
||||
{"call": "ui.scroll", "params": {"x": 800, "y": 500, "delta": -3000}},
|
||||
{"expect": {"shown": "Colour vision", "prefs": {"dialog": "Graphics"}, "same_width": "prefs:window"}},
|
||||
{"widget": "Off"},
|
||||
{"widget": "Deutan"},
|
||||
@@ -11,12 +12,14 @@
|
||||
{"expect": {"prefs": {"dialog": null, "color_vision": "off"}}},
|
||||
{"key": "Comma", "cmd": true},
|
||||
{"widget": "Graphics"},
|
||||
{"call": "ui.scroll", "params": {"x": 800, "y": 500, "delta": -3000}},
|
||||
{"widget": "Off"},
|
||||
{"widget": "Tritan"},
|
||||
{"widget": "OK"},
|
||||
{"expect": {"prefs": {"dialog": null, "color_vision": "tritan"}}},
|
||||
{"key": "Comma", "cmd": true},
|
||||
{"widget": "Graphics"},
|
||||
{"call": "ui.scroll", "params": {"x": 800, "y": 500, "delta": -3000}},
|
||||
{"expect": {"shown": "Tritan", "same_width": "prefs:window"}},
|
||||
{"widget": "Restore Defaults"},
|
||||
{"expect": {"prefs": {"color_vision": "off"}}},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[
|
||||
{
|
||||
"note": "#323: every pinned command gets a toolbar button, with no fixed cap. Ten commands pinned to SOLID > CREATE all show as buttons at a wide window; at a narrow one the panel collapses into its drop-down, then into the » menu, and none is dropped."
|
||||
"note": "#323: every pinned command gets a toolbar button, with no fixed cap. Ten commands pinned to SOLID > CREATE all show as buttons at a wide window; at a narrower one the panel keeps the buttons that fit and its drop-down lists the rest (#2108), then it moves into the » menu, and none is dropped."
|
||||
},
|
||||
{
|
||||
"call": "ui.resize",
|
||||
@@ -229,7 +229,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "A narrow window: CREATE collapses into one button that opens its drop-down, which lists every pinned command."
|
||||
"note": "A narrower window: CREATE keeps its first buttons and hands the rest to its drop-down, which lists every pinned command (#2108)."
|
||||
},
|
||||
{
|
||||
"call": "ui.resize",
|
||||
@@ -241,26 +241,18 @@
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": [
|
||||
"button:solid.box",
|
||||
"button:solid.cylinder",
|
||||
"button:solid.sphere",
|
||||
"button:solid.torus",
|
||||
"button:solid.coil",
|
||||
"button:solid.loft",
|
||||
"button:solid.mirror",
|
||||
"button:solid.hole",
|
||||
"button:solid.pipe",
|
||||
"button:solid.thread"
|
||||
],
|
||||
"shown_handles": [
|
||||
"collapsed:SOLID:CREATE",
|
||||
"button:solid.extrude",
|
||||
"panel:SOLID:CREATE"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "collapsed:SOLID:CREATE"
|
||||
"handle": "panel:SOLID:CREATE"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,6 +116,32 @@ cd apps/solidcraft-web && trunk serve --release --no-autoreload
|
||||
Offline use needs `--no-autoreload`: Trunk's live reload changes the page as it serves it, so it
|
||||
no longer matches the build the browser would keep. Debug builds (`trunk serve`) never keep a copy.
|
||||
|
||||
### Self-hosting with Docker
|
||||
|
||||
The web app is also a container image, `ghcr.io/tangency-labs/solidcraft-web`, for Linux on x86_64
|
||||
and ARM64: `latest` is the latest release, `0.14.0` or `0.14` a release, `edge` the newest `main`.
|
||||
It serves the app with nginx on port 8080 and keeps nothing on the server: designs stay in each
|
||||
visitor's browser.
|
||||
|
||||
```sh
|
||||
docker run -d --name solidcraft -p 8080:8080 --restart unless-stopped ghcr.io/tangency-labs/solidcraft-web:latest
|
||||
```
|
||||
|
||||
Or with Docker Compose, using
|
||||
[`packaging/docker/compose.yaml`](https://github.com/tangency-labs/solidcraft/blob/main/packaging/docker/compose.yaml)
|
||||
(`SOLIDCRAFT_PORT` picks another port):
|
||||
|
||||
```sh
|
||||
docker compose -f packaging/docker/compose.yaml up -d # start
|
||||
docker compose -f packaging/docker/compose.yaml pull && docker compose -f packaging/docker/compose.yaml up -d # update
|
||||
docker compose -f packaging/docker/compose.yaml up -d --build # build the image from your checkout instead
|
||||
```
|
||||
|
||||
Then open `http://localhost:8080/`. On another machine the browser only saves designs over HTTPS,
|
||||
so put a reverse proxy with a certificate in front (Caddy, Traefik, nginx); the app works under
|
||||
any URL path, such as `https://example.com/cad/`. To build the image without Compose, from the
|
||||
repository root: `docker build -f packaging/docker/Dockerfile -t solidcraft-web .`
|
||||
|
||||
### 3D mouse in the browser
|
||||
|
||||
The web app reads a 3Dconnexion SpaceMouse or SpaceNavigator through the browser's Gamepad API,
|
||||
|
||||
@@ -5,6 +5,13 @@ are ones you add yourself, with a unit, an expression and an optional comment. C
|
||||
**Change Parameters** (`parameters.change`). The timeline rebuilds from the first feature that
|
||||
uses them.
|
||||
|
||||
When creating or editing a sketch dimension, its value editor shows its model parameter
|
||||
name (`d1`, `d2`, …). Double-click another driving dimension to insert its name at the cursor,
|
||||
replacing selected text; add arithmetic such as `/ 2` to build a dependent dimension.
|
||||
Single clicks on dimensions leave the expression unchanged. Press **Enter** to apply the
|
||||
expression or **Escape** to cancel the edit.
|
||||
Add and edit user parameters through the existing **Change Parameters** command.
|
||||
|
||||
In the SOLID toolbar's CONFIGURE panel, click **Parameters** to open the user and model
|
||||
parameter editor. The separate **Configurations** button opens the configuration table;
|
||||
**Configure** remains in the dropdown for configuring columns.
|
||||
|
||||
@@ -46,6 +46,19 @@ background would install code macOS can't vouch for without you seeing it.
|
||||
Update now needs write access to the installed file: an AppImage in a folder you can't write to
|
||||
fails with a message, and **Download** is the way to update it.
|
||||
|
||||
## Updating from 0.11 to 0.14
|
||||
|
||||
SolidCraft 0.11.0 to 0.14.0 only accept packages from the project's old GitHub address. Since
|
||||
the repository moved to `github.com/tangency-labs/solidcraft`, every release names the new address,
|
||||
so these versions see that a newer release is out but find no package in it: **Update now** can't
|
||||
install it, and **Download** opens the release page.
|
||||
|
||||
Download 0.15.0 or later once yourself, from the release page or the
|
||||
[download links](https://github.com/tangency-labs/solidcraft#download), and install it over the old
|
||||
version as you first installed it. From 0.15.0 on, SolidCraft accepts either address, and
|
||||
**Update now** works again. Package managers (Flatpak, Snap, WinGet, `.deb`, `.rpm`) aren't
|
||||
affected.
|
||||
|
||||
## What is sent
|
||||
|
||||
One HTTPS request each time SolidCraft starts, and once a day while it stays open:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# UX redesign proposal: ribbon layout
|
||||
|
||||
Design mockups for a new desktop shell. They are pictures only: nothing in the app changes with
|
||||
this proposal. The goal is a layout that is familiar to people coming from other CAD software and
|
||||
easy for new users, built from standard patterns, with the current single-row toolbar replaced by
|
||||
a ribbon whose tabs are ordered by task instead of by geometry type.
|
||||
|
||||

|
||||
|
||||
## The design
|
||||
|
||||
**Application bar** (dark): logo and name, a house icon that opens the start page, Save, Undo,
|
||||
Redo, the design tabs (each with a close button), then the agent status, Settings, Help and
|
||||
Report a bug. There is no search box here, no dark-mode toggle and no status bar.
|
||||
|
||||
**Ribbon**: a workspace card at the left (Design and Render), tabs **Model, Surface, Assemble, Insert, Inspect, Manage, View**, and a small command
|
||||
search at the right end of the tab row (also the S key). Each section shows one to four promoted
|
||||
commands. Its label (▾) opens a list of every command in the section, and hovering a row pins it
|
||||
to the ribbon, as in the current app. Tooltips show the shortcut and modifier keys.
|
||||
|
||||
**Contextual tabs** appear when the design needs them and are selected automatically:
|
||||
**Sketch** while a sketch is open (Create, Modify, Pattern, Constrain, Reference), **Sheet Metal** when the active body has a sheet-metal rule.
|
||||
Nothing is greyed out, and the other tabs stay available. Plastic features live in
|
||||
Model › Create ▾ and plastic rules in Manage, so there is no Plastic tab.
|
||||
|
||||
**Viewport and panels**: the viewport is full-bleed under the application bar. The view compass is
|
||||
fixed at the top right and panels open below it. History is a full-width icon strip along the
|
||||
bottom edge.
|
||||
|
||||
**Start page**: the house icon opens a full-page view with New, Open, Recent, Samples and crash
|
||||
recovery; Save As and Export are there too. The camera's Home view stays in the navigation bar.
|
||||
|
||||
**Workspaces** are product modes that share the same design, history and selection and change only
|
||||
the ribbon tabs. The geometry types (Solid, Surface, Mesh) are tabs, not workspaces. Only Design
|
||||
and Render exist today and are the scope of this work; the switcher mockup also greys out the
|
||||
entries the app's own workspace menu already lists as "coming later".
|
||||
|
||||
| Board | What it shows |
|
||||
|---|---|
|
||||
| [Command map](command-map.png) | All 389 commands regrouped by task, and where today's panels go |
|
||||
| [Floating](model-floating.png) | Model tab with the ribbon, browser and dialog floating over the viewport, Modify list open |
|
||||
| [Compact](model-compact.png) | The same tab docked to the window edges (see below) |
|
||||
| [Sketch](sketch.png) | Sketch tab with Finish Sketch as its own ribbon group |
|
||||
| [Sheet metal](sheet-metal.png) | The contextual tab opened on a sheet-metal body |
|
||||
| [Start page](start-page.png) | The house icon's full-page view |
|
||||
| [Workspaces](workspaces.png) | The workspace switcher, open |
|
||||
|
||||
## Options
|
||||
|
||||
**Floating** (above) keeps the ribbon, browser and dialogs as rounded cards over a full-bleed
|
||||
viewport. It looks lighter and keeps the 3D view visible around every panel.
|
||||
|
||||
**Compact** docks the ribbon and the workspace card into one bar at the top with no gap, docks the
|
||||
browser flush to the left edge and ribbon (concave corner where they meet, only as tall as its
|
||||
contents), and docks a denser feature panel to the right edge below the compass. Spacing is a
|
||||
little tighter throughout. It maximises viewport space and still looks finished.
|
||||
|
||||

|
||||
|
||||
Both options use the same dense feature panel: 24 px rows, a narrow label column, two-column
|
||||
options and collapsed sections, so commands with 10 to 20 options fit.
|
||||
|
||||
## Not included
|
||||
|
||||
- The earlier sketch variant with tab locks and a Finish menu was set aside as too complex for a
|
||||
first version, and is not part of this proposal.
|
||||
- The icons in the mockups are the reviewed SVGs from the icon system in PR #853
|
||||
(`assets/icons/ui/design/svg/`), coloured with its accents from `palette.toml`: Sketch tools in
|
||||
the Sketch green, constraints in red, everything else in the Solid blue. The greyed Simulation and
|
||||
Manufacture entries in the switcher mockup use placeholder icons and are not part of this work. This proposal does not
|
||||
implement or change any app icons.
|
||||
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 583 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 487 KiB |
|
After Width: | Height: | Size: 273 KiB |
@@ -1,8 +1,8 @@
|
||||
# Features
|
||||
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **391 commands.** Planned (registered, not built yet): 1. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **393 commands.** Planned (registered, not built yet): 1. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
|
||||
## Sketch (103)
|
||||
## Sketch (105)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
@@ -76,6 +76,8 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Insert DXF | `sketch.insert_dxf` | SKETCH › INSERT |
|
||||
| Insert SVG | `sketch.insert_svg` | SKETCH › INSERT |
|
||||
| Save As DXF | `sketch.export_dxf` | – |
|
||||
| Loose Ends | `sketch.loose_ends` | – |
|
||||
| Tie Loose Ends | `sketch.loose_ends.tie` | – |
|
||||
| 3D Line | `sketch.line3d` | – |
|
||||
| 3D Spline | `sketch.spline3d` | – |
|
||||
| 3D Point | `sketch.point3d` | – |
|
||||
|
||||
@@ -46,6 +46,13 @@ worse, so a kernel change shows every seed it moves. Pull requests that touch `c
|
||||
run every 8th seed; pushes to `main` and the nightly run them all. Each seed is its own test
|
||||
(`seed_00` … `seed_95`), so the CI shards and their cores share them out.
|
||||
|
||||
Explicit `platforms.<os>.seeds` records override only the listed seeds on that
|
||||
operating system; all other seeds keep the shared records and strict tolerances.
|
||||
The first four Windows records were independently reproduced on unmodified main
|
||||
`1e984c8e`; seed 94 was independently reproduced on unmodified main `c5992386`
|
||||
after the extrusion rebase. Both match the extrusion branch. Recording on a platform
|
||||
with such entries updates its changed seeds and preserves shared references.
|
||||
|
||||
To record an intended change, give the reason:
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"description": "Issue #2005: redefine a 90 degree new-body sweep around a circle to a 180 degree cut inside a torus. A closed tool must join back to its starting section without coincident caps.",
|
||||
"commands": [
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Path"}},
|
||||
{"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 30}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "sketch.create", "params": {"plane": "XZ", "name": "Tube"}},
|
||||
{"command": "sketch.circle.center", "params": {"center": [30, 0], "radius": 8}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.revolve", "params": {"axis": "Z", "body_name": "Torus"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XZ", "name": "SweepProfile"}},
|
||||
{"command": "sketch.line", "params": {"points": [[26, -4], [34, -4], [34, 4], [26, 4]], "closed": true}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.sweep", "params": {"sketch": 4, "path_sketch": 1, "path": ["c1"], "operation": "new_body", "twist": "90 deg"}},
|
||||
{"command": "timeline.redefine", "params": {"feature": 5, "command": "solid.sweep", "params": {"sketch": 4, "path_sketch": 1, "path": ["c1"], "profiles": [0], "operation": "cut", "twist": "180 deg"}}}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"description": "Reduced reproduction for #2059, constructed from the public SolidCraft screenshot: eighteen 1 x 2 mm tabs extend six ribs on a chamfered round base. The tabs share the ribs' side planes; the remote conical chamfer made the join fail with Union: no result. This is an original analytic example, not the reporter's unavailable design.",
|
||||
"commands": [
|
||||
{"command": "solid.cylinder", "params": {"radius": 40, "height": 4, "body_name": "Base"}},
|
||||
{"command": "solid.chamfer", "params": {"edges": [[40, 0, 4]], "distance": 1}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Ribs"}},
|
||||
{"command": "sketch.line", "params": {"points": [[5.0, -1.0], [37.0, -1.0], [37.0, 1.0], [5.0, 1.0]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[3.366025404, 3.830127019], [19.366025404, 31.54293994], [17.633974596, 32.54293994], [1.633974596, 4.830127019]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[-1.633974596, 4.830127019], [-17.633974596, 32.54293994], [-19.366025404, 31.54293994], [-3.366025404, 3.830127019]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[-5.0, 1.0], [-37.0, 1.0], [-37.0, -1.0], [-5.0, -1.0]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[-3.366025404, -3.830127019], [-19.366025404, -31.54293994], [-17.633974596, -32.54293994], [-1.633974596, -4.830127019]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[1.633974596, -4.830127019], [17.633974596, -32.54293994], [19.366025404, -31.54293994], [3.366025404, -3.830127019]], "closed": true}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"sketch": "Ribs", "distance": 8, "operation": "join"}},
|
||||
{"command": "sketch.create", "params": {"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Tabs"}},
|
||||
{"command": "sketch.line", "params": {"points": [[15.0, -1.0], [16.0, -1.0], [16.0, 1.0], [15.0, 1.0]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[25.0, -1.0], [26.0, -1.0], [26.0, 1.0], [25.0, 1.0]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[35.0, -1.0], [36.0, -1.0], [36.0, 1.0], [35.0, 1.0]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[8.366025404, 12.490381057], [8.866025404, 13.356406461], [7.133974596, 14.356406461], [6.633974596, 13.490381057]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[13.366025404, 21.150635095], [13.866025404, 22.016660498], [12.133974596, 23.016660498], [11.633974596, 22.150635095]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[18.366025404, 29.810889132], [18.866025404, 30.676914536], [17.133974596, 31.676914536], [16.633974596, 30.810889132]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[-6.633974596, 13.490381057], [-7.133974596, 14.356406461], [-8.866025404, 13.356406461], [-8.366025404, 12.490381057]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[-11.633974596, 22.150635095], [-12.133974596, 23.016660498], [-13.866025404, 22.016660498], [-13.366025404, 21.150635095]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[-16.633974596, 30.810889132], [-17.133974596, 31.676914536], [-18.866025404, 30.676914536], [-18.366025404, 29.810889132]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[-15.0, 1.0], [-16.0, 1.0], [-16.0, -1.0], [-15.0, -1.0]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[-25.0, 1.0], [-26.0, 1.0], [-26.0, -1.0], [-25.0, -1.0]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[-35.0, 1.0], [-36.0, 1.0], [-36.0, -1.0], [-35.0, -1.0]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[-8.366025404, -12.490381057], [-8.866025404, -13.356406461], [-7.133974596, -14.356406461], [-6.633974596, -13.490381057]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[-13.366025404, -21.150635095], [-13.866025404, -22.016660498], [-12.133974596, -23.016660498], [-11.633974596, -22.150635095]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[-18.366025404, -29.810889132], [-18.866025404, -30.676914536], [-17.133974596, -31.676914536], [-16.633974596, -30.810889132]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[6.633974596, -13.490381057], [7.133974596, -14.356406461], [8.866025404, -13.356406461], [8.366025404, -12.490381057]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[11.633974596, -22.150635095], [12.133974596, -23.016660498], [13.866025404, -22.016660498], [13.366025404, -21.150635095]], "closed": true}},
|
||||
{"command": "sketch.line", "params": {"points": [[16.633974596, -30.810889132], [17.133974596, -31.676914536], [18.866025404, -30.676914536], [18.366025404, -29.810889132]], "closed": true}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"sketch": "Tabs", "distance": 16, "operation": "join"}}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# SolidCraft in the browser as a container: the static site (the one packaging/web/package.sh
|
||||
# zips) served by nginx on port 8080, as an unprivileged user. From the repository root:
|
||||
#
|
||||
# docker build -f packaging/docker/Dockerfile -t solidcraft-web .
|
||||
# docker run --rm -p 8080:8080 solidcraft-web then open http://localhost:8080/
|
||||
#
|
||||
# Or `docker compose up` with packaging/docker/compose.yaml. Saving designs needs HTTPS or
|
||||
# localhost (the browser's private file storage), so put a TLS reverse proxy in front of it on a
|
||||
# server; the site works under any URL path.
|
||||
#
|
||||
# The site is wasm, the same on every platform, so it is built once on the build machine's own
|
||||
# platform even for a multi-platform image. CI builds it outside Docker (with its Rust cache and
|
||||
# git commit for Help › About) and passes it in as the `site` stage: --build-context site=dist/web.
|
||||
|
||||
ARG RUST_VERSION=1.95
|
||||
ARG TRUNK_VERSION=0.21.14
|
||||
|
||||
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS build
|
||||
ARG TRUNK_VERSION
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN rustup target add wasm32-unknown-unknown \
|
||||
&& curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-$(uname -m)-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin trunk
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
WORKDIR /src/apps/solidcraft-web
|
||||
# Trunk downloads the wasm-bindgen and wasm-opt its build needs; its post-build hook writes sw.js.
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/src/target \
|
||||
trunk build --release
|
||||
|
||||
FROM scratch AS site
|
||||
COPY --from=build /src/dist/web/ /
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:1.29-alpine
|
||||
COPY packaging/docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY LICENSE-MIT LICENSE-APACHE NOTICE THIRD-PARTY-LICENSES.txt /usr/share/doc/solidcraft/
|
||||
COPY --from=site / /srv/solidcraft/
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s CMD wget -q --spider http://127.0.0.1:8080/ || exit 1
|
||||
@@ -0,0 +1,7 @@
|
||||
# Build context for packaging/docker/Dockerfile (the repository root): the sources the web build
|
||||
# needs, without build output, git history or local files.
|
||||
.git
|
||||
**/target*
|
||||
dist
|
||||
plan
|
||||
docs/screenshots
|
||||