Compare commits

...
Author SHA1 Message Date
bherbruck c59842b8e1 Merge branch 'main' into ci/slow-runner-timeouts 2026-10-17 20:26:18 -04:00
bherbruckandClaude Opus 5.5 2f31138f9d CI: a pull request fast lane; the heavy checks run on main (#2278)
Pull requests now run the repository checks, Clippy, the affected
crates' tests in 2 shards, and the doc tests. Windows, the reference
tests and the robustness goldens run on pushes to main and in the
nightly, where a break is fixed forward. This is the split Bevy, Zed
and Ruff use, and it cuts each pull request's CI to about a third.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 20:25:13 -04:00
8c503ad1be Revert "CI: run untrusted and GitHub-hosted Linux jobs on Blacksmith's Ubuntu 24.04 runners (#2237)" (#2277)
Blacksmith isn't provisioning runners: its dashboard says the account
"requires additional verification", so every job labelled blacksmith-*
stays queued with no runner. Fork PRs' Linux jobs go back to the
self-hosted-untrusted pool (#2249/#2252) and the rest to GitHub's runners
until Blacksmith verifies the account; then #2237 can be re-applied.

This reverts commit dc40f051b1.


Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 20:23:57 -04:00
bherbruckandClaude Opus 5.5 a587d3339e Tests: more time for the web offline launches and the icon review server
On a busy CI machine (several fork jobs under gVisor at once) a web
offline launch took over 90 s and the icon review server took over ~2 s
to answer. Launches now get 240 s and one retry; the review server gets
up to 60 s to start.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 00:11:14 +00:00
dc40f051b1 CI: run untrusted and GitHub-hosted Linux jobs on Blacksmith's Ubuntu 24.04 runners (#2237)
Fork pull requests, the nightly run, Run workflow, the test archive and
test shards 3-4 move from ubuntu-24.04 to blacksmith-<N>vcpu-ubuntu-2404
(16 vCPU for Clippy and the test archive, 8 for test shards, doc tests,
reference tests and cross-compiles). Trusted runs keep the self-hosted
routing. Blacksmith runners count as self-hosted to GitHub, so steps now
tell our own machines apart by runner name.

TEMPORARY: this branch routes its own run like a fork pull request.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: jyviko <[email protected]>
2026-10-17 20:04:20 -04:00
bherbruckandClaude Opus 5.5 712b427a9c CI: a fork PR's Select checks and ci jobs run on the untrusted runners too (#2252)
Every fork run waited in GitHub's shared queue just to start. The gate
and summary jobs now run on the isolated runners as well; the local
output stays false for forks so later jobs never pick the trusted pool.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 19:37:10 -04:00
bherbruckandClaude Opus 5.5 e48e553758 CI: fork PRs run their Linux jobs on the isolated self-hosted runners (#2249)
Fork PRs were waiting hours for GitHub's shared runners. Their Linux
jobs (checks, Clippy, Build tests, the test shards, Doc tests, Reference
tests) now run on the ephemeral, network-jailed self-hosted-untrusted
runners, without the trusted runners' warm build directories.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 19:27:13 -04:00
github-actions[bot] dc8edead22 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-17 23:22:01 +00:00
JIJINBEI 2acc344aa4 Sketch: show open and untied profile ends (#2000) (#2001)
Thanks @jijinbei.
2026-10-17 18:52:12 -04:00
Koschtetnix 487aa64e10 Multi profile extrusion performance (#2064)
Thanks @Koschtetnix.
2026-10-17 18:30:52 -04:00
Mikalai Kukhta 180e62c981 Macos space mouse support (#1815)
Thanks @NecroKote.
2026-10-17 18:15:15 -04:00
Karl Von Richter 11a196e6f8 sketch(feat): add dimension references and parameter tools (#1548)
Show each dimension's parameter name while creating or editing it, and let
users insert another driving dimension at the expression cursor or selection.
Use the available editor width and keep Apply aligned with the right edge.

Add a tools dropdown with Measure, named user parameter expressions, and
access to the existing parameter table for adding and editing parameters.
Preserve the unfinished dimension expression while either window is open.

Cover reference insertion, dependent updates, parameter selection, dialog
round trips, applying and cancellation with UI integration tests. Update the
parameter guide.

Signed-off-by: Karl Von Richter<[email protected]>
2026-10-18 00:00:35 +02:00
Thomas Kroll a11446fd17 Web: a launch behind an expired sign-in shows the sign-in instead of the kept build (#2151)
Thanks @nofuturekid.
2026-10-17 17:54:29 -04:00
DuBearte 7a3221d2dd Updates: 0.11–0.14 must install 0.15 by hand once (#2136) (#2141)
Thanks @DuBearte.
2026-10-17 17:28:04 -04:00
bherbruck e8776d8583 Toolbar fills the available width instead of leaving a gap (#2108) (#2109)
The toolbar gives up room in small steps (labels shorten, then icons only, then panels hand buttons to their drop-down one at a time, then trailing panels go to the » menu) instead of collapsing whole panels, so medium-width windows no longer lose icons or leave a blank gap.
2026-10-17 17:08:51 -04:00
Pablo López cb857f1fa7 Rib/Web: grow along the sketch normal (#2056) (#2057)
Thanks @pablopeza.
2026-10-17 14:15:03 -04:00
bherbruckandClaude Opus 5.5 e347f820e6 Fix red main: give inspect.silhouette an icon; scroll to Colour vision in its scenario (#2134)
Preferences > Graphics grew a row (Grid follows the view, #2035), so the Colour vision
control now sits below the fold of the fixed-size page; the scenario scrolls to it.
The page itself already scrolls for real users.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 13:53:01 -04:00
rpromas 6597ffe072 Self-host the web app with Docker; publish the image to ghcr.io (#2033)
Thanks @rpromas.
2026-10-17 13:49:07 -04:00
Mateusz Sych 4a70c03deb Revolve: a profile that comes back to the axis along a curve revolves right, and cuts with it work (#1853) (#1866)
Thanks @SychPL.
2026-10-17 13:38:41 -04:00
fkirill f480898158 Extrude: join extensions of ribs on curved bases (#2059) (#2082)
Thanks @fkirill.
2026-10-17 13:33:21 -04:00
Moritz 9be8bb5e6b Fix Steam Frame STEP trims and spline projection (#1974) (#2043)
Thanks @moritzuehling.
2026-10-17 13:33:17 -04:00
JEREMY 843fa0dac6 Grid: hysteresis and no wall swap at the 45° crossing behind Grid follows the view (#1993) (#2050)
Thanks @jeremymiribung-blip.
2026-10-17 12:51:38 -04:00
fkirill 2c924fd385 Sweep: close circular tools before cutting a torus (#2005) (#2010)
Thanks @fkirill.
2026-10-17 12:46:21 -04:00
bherbruckandClaude Opus 5.5 8be736c33e CI: trusted pull requests use the self-hosted runners too (#2012)
Pull requests from branches of this repository share the work with the self-hosted runners like main does; fork pull requests stay on GitHub's runners. Select checks and ci run on a light self-hosted runner; the repository checks, Clippy, doc tests, reference tests and test shards 1-2 on the fast ones. The test archive is still built on GitHub's runners (glibc).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 12:00:38 -04:00
selune f3494efbbb Fix complete Japanese and Chinese grid-follow-view messages (#2092)
* claim: complete Japanese and Chinese grid-follow-view messages

* fix(i18n): complete grid-follow-view messages in Japanese and Chinese
2026-10-17 17:56:39 +02:00
bherbruckandClaude Opus 5.5 6fe16b790b Link the Discord invite directly (#2094)
Some security filters block the solidcraft.io/discord redirect.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 11:55:21 -04:00
bherbruck 979614e60f Release 0.15.0 (#2039)
Version 0.15.0; pending design-format changes folded into format 26.
2026-10-17 11:55:05 -04:00
Nathan DaneandClaude Sonnet 5.5 8a31738fe5 docs: ribbon redesign proposal (#1439)
* docs: ribbon UX redesign proposal (mockups and notes)

Seven mockup images and a short description of the proposed ribbon shell, with floating and
compact layout options. Pictures only; no app changes. The sketch variant with tab locks is
left out.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* docs: update the UX mockups to the current command set

Adds the Sketch Pattern panel, Perforate, new joints and motion commands, Fair and Match Surface,
and the Render workspace; the workspace switcher now matches the app (Design and Render available,
Animation, Simulation, Manufacture and Drawing later). Command map covers all 389 commands.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* docs: draw the UX mockups with the reviewed icon SVGs and accents

Ribbon, browser, history and navigation icons now come from the icon system in #853, with the
Sketch tab in the Sketch green and constraints in red.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* docs: address review on the UX proposal README

Describe the current toolbar in our own terms, and list only Design and Render as the workspaces in
scope.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-17 11:36:43 -04:00
f7343e9161 CI: a main push the merge queue already tested runs only repository checks (#1860)
The merge queue runs the full suite on exactly the commit it then puts on
main, and main's push run then ran the full suite again on the same files.
Select checks now asks the Actions API whether a passing merge_group CI run
exists for the pushed commit; if so the push runs only the repository checks.
A push that skipped the queue, or an API that cannot answer, still runs
everything. The nightly still runs the whole suite.

Pull requests also run less: the reference tests only when solidcraft-cli (or
anything it depends on) is affected, and doc tests only for the affected
packages that have a library. The ci check requires the reference tests only
when they were selected.


Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 10:21:08 -04:00
111 changed files with 5372 additions and 395 deletions

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -1,3 +1,4 @@
self-hosted-runner:
labels:
- self-hosted-fast
- self-hosted-light
+105 -35
View File
@@ -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
+105
View File
@@ -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 }}
+7
View File
@@ -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 |
+4 -2
View File
@@ -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
+5 -4
View File
@@ -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.
Generated
+21 -16
View File
@@ -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",
+2 -1
View File
@@ -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"
+15 -4
View File
@@ -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).
+19
View File
@@ -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.
+12 -4
View File
@@ -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];
+6 -1
View File
@@ -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"] }
+6 -6
View File
@@ -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,
}))
}),
+57 -6
View File
@@ -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,
+3
View File
@@ -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",
+3
View File
@@ -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",
+11
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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é",
+3
View File
@@ -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",
+13
View File
@@ -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": "現在のビューに更新",
+3
View File
@@ -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": "전체 길이",
+3
View File
@@ -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",
+3
View File
@@ -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": "Непрозрачность",
+3
View File
@@ -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ç",
+3
View File
@@ -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": "Непрозорість",
+13
View File
@@ -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": "更新为当前视图",
+9 -1
View File
@@ -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]
+6 -1
View File
@@ -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 {
+195 -4
View File
@@ -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 &regions {
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() }
}
+79 -2
View File
@@ -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.
+1 -1
View File
@@ -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, &params).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, &params).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 &centers {
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);
}
}
}
+78
View File
@@ -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.
+52 -14
View File
@@ -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");
+8 -3
View File
@@ -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, &params), 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, &params), Some(expected));
params["face_body"] = json!("Stock");
assert_eq!(crate::auto_operation(&s, &params), 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, &params), Some("join"), "an existing active-component body keeps its original participant scope");
params["participants"] = json!(["Stock"]);
assert_eq!(crate::auto_operation(&s, &params), Some("cut"));
params["participants"] = json!(["Other"]);
assert_eq!(crate::auto_operation(&s, &params), 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();
+2
View File
@@ -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());
+1 -1
View File
@@ -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",
)];
+97
View File
@@ -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());
}
+19 -6
View File
@@ -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).
+3 -1
View File
@@ -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)],
+41 -5
View File
@@ -54,6 +54,24 @@ fn differs(want: &Value, got: &Value) -> Option<String> {
(!out.is_empty()).then(|| out.join("; "))
}
/// Platform records only override the seeds explicitly verified on that platform.
fn expected<'a>(file: &'a Value, seed: u64, platform: &str) -> &'a Value {
let key = seed.to_string();
file.get("platforms")
.and_then(|platforms| platforms.get(platform))
.and_then(|record| record.get("seeds"))
.and_then(|seeds| seeds.get(&key))
.unwrap_or(&file["seeds"][key])
}
#[test]
fn platform_records_keep_shared_references_for_every_other_seed_and_platform() {
let file = json!({"seeds":{"1":{"volume":10},"2":{"volume":20}},"platforms":{"windows":{"seeds":{"1":{"volume":11}}}}});
assert_eq!(expected(&file, 1, "windows")["volume"], 11);
assert_eq!(expected(&file, 1, "linux")["volume"], 10);
assert_eq!(expected(&file, 2, "windows")["volume"], 20);
}
fn check(seed: u64) {
let got: BTreeMap<u64, Value> = BTreeMap::from([(seed, fuzz::golden(seed, MAX_STEPS))]);
let _lock = FILE.lock().unwrap_or_else(|e| e.into_inner());
@@ -61,7 +79,7 @@ fn check(seed: u64) {
let changed: Vec<(u64, String)> = got
.iter()
.filter_map(|(seed, g)| {
let w = &file["seeds"][seed.to_string()];
let w = expected(&file, *seed, std::env::consts::OS);
if w.is_null() { Some((*seed, "new".to_string())) } else { differs(w, g).map(|d| (*seed, d)) }
})
.collect();
@@ -75,20 +93,34 @@ fn check(seed: u64) {
"What each robustness seed builds (crates/engine/src/tests/robustness_goldens.rs, docs/robustness.md). Record changes with ROBUSTNESS_GOLDENS_BLESS=\"why\"."
);
}
let platform = std::env::consts::OS;
let platform_record = file["platforms"][platform]["seeds"].is_object();
for (seed, g) in &got {
file["seeds"][seed.to_string()] = g.clone();
if platform_record {
if changed.iter().any(|(changed, _)| changed == seed) {
file["platforms"][platform]["seeds"][seed.to_string()] = g.clone();
}
} else {
file["seeds"][seed.to_string()] = g.clone();
}
}
let mut notes = file["notes"].as_array().cloned().unwrap_or_default();
let seeds: Vec<u64> = changed.iter().map(|(s, _)| *s).collect();
// The parts of one record (the tests run side by side) share one note.
match notes.last_mut() {
Some(last) if last["reason"] == json!(reason) => {
Some(last) if last["reason"] == json!(reason) && (!platform_record || last["platform"] == json!(platform)) => {
let mut all: Vec<u64> = last["seeds"].as_array().into_iter().flatten().filter_map(Value::as_u64).chain(seeds).collect();
all.sort_unstable();
all.dedup();
last["seeds"] = json!(all);
}
_ => notes.push(json!({"reason": reason, "seeds": seeds})),
_ => {
let mut note = json!({"reason": reason, "seeds": seeds});
if platform_record {
note["platform"] = json!(platform);
}
notes.push(note);
}
}
file["notes"] = Value::Array(notes);
// Seeds in order, one per line, so a change is a small diff.
@@ -100,7 +132,11 @@ fn check(seed: u64) {
s += &format!(" \"{k}\": {}{}\n", v, if i + 1 < n { "," } else { "" });
}
let notes: Vec<String> = file["notes"].as_array().into_iter().flatten().map(|n| format!(" {n}")).collect();
s += &format!(" }},\n \"notes\": [\n{}\n ]\n}}\n", notes.join(",\n"));
s += " },\n";
if let Some(platforms) = file.get("platforms") {
s += &format!(" \"platforms\": {platforms},\n");
}
s += &format!(" \"notes\": [\n{}\n ]\n}}\n", notes.join(",\n"));
std::fs::write(path(), s).unwrap_or_else(|e| panic!("{}: {e}", path().display()));
return;
}
+53 -13
View File
@@ -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);
+27
View File
@@ -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))
+237 -78
View File
@@ -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;
+43 -1
View File
@@ -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;
}
+93
View File
@@ -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(), &region, &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}");
}
+6 -1
View File
@@ -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};
+271 -9
View File
@@ -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 &region.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, &regions, 0.000_105, 200.).unwrap();
assert!(tools.iter().all(|tool| !can_join_touching_faces(&target, tool)));
let compound = compound_disjoint_extrusions(&tools).unwrap().unwrap();
assert!(!can_join_all_touching_faces(&target, &compound));
}
#[test]
fn extrusion_compound_requires_separated_construction_bounds() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let rectangle = |x: f64| Region2 {
outer: Loop2::polygon(&[Vec2::new(x, 0.0), Vec2::new(x + 2.0, 0.0), Vec2::new(x + 2.0, 2.0), Vec2::new(x, 2.0)]),
holes: vec![],
};
let plane = Plane::from_normal(Vec3::new(100.0, -20.0, 8.0), Vec3::new(1.0, 2.0, 3.0)).unwrap();
let tools = crate::extrude(&plane, &[rectangle(0.0), rectangle(20.0)], -1.0, 5.0).unwrap();
let compound = compound_disjoint_extrusions(&tools).unwrap().unwrap();
assert_eq!(compound.shell_count(), 2);
assert_eq!(compound.history().unwrap().operands, 2);
assert!((crate::measure(&compound).unwrap().volume - 48.0).abs() < 1e-8);
assert!(compound.validity().is_empty());
for x in [1.0, 2.0, 2.000_000_1] {
let touching = crate::extrude(&solidcraft_geom::Plane::XY, &[rectangle(0.0), rectangle(x)], 0.0, 5.0).unwrap();
assert!(compound_disjoint_extrusions(&touching).unwrap().is_none());
}
let unsupported = [crate::sphere(Vec3::ZERO, 1.0).unwrap(), crate::sphere(Vec3::new(10.0, 0.0, 0.0), 1.0).unwrap()];
assert!(compound_disjoint_extrusions(&unsupported).unwrap().is_none());
}
#[test]
fn extrusion_compound_includes_arc_extrema_between_seam_vertices() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let circle = |x| Region2 { outer: Loop2::circle(Vec2::new(x, 0.0), 2.0), holes: vec![] };
let separated = crate::extrude(&Plane::XY, &[circle(0.0), circle(8.0)], 0.0, 5.0).unwrap();
assert!(compound_disjoint_extrusions(&separated).unwrap().is_some());
let overlapping = crate::extrude(&Plane::XY, &[circle(0.0), circle(3.0)], 0.0, 5.0).unwrap();
assert!(compound_disjoint_extrusions(&overlapping).unwrap().is_none());
}
#[test]
fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() {
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
@@ -513,7 +772,11 @@ fn meeting_size(a: &Body, b: &Body) -> f64 {
};
let (ba, bb) = (bbox(a), bbox(b));
let s = solidcraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size();
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
// A closed sweep's section seams need not span its geometry. Roundoff in a flat
// vertex box is not a thin meeting region: refining for it
// makes millions of triangles and skips the bounded containment shortcut (#2005).
let roundoff = a.size().max(b.size()) * 1e-12;
if s.x > roundoff && s.y > roundoff && s.z > roundoff { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
}
/// Smallest side of the box where the bounds of two meshes overlap (infinite when they don't).
@@ -739,14 +1002,13 @@ fn boolean_whole_with_refinement(a: &Body, b: &Body, op: BoolOp, refine: bool) -
}
}
// Bodies touching along one whole face (a part joined with its mirror image).
if op == BoolOp::Union
&& a.solid.boundaries().len() == 1
&& b.solid.boundaries().len() == 1
&& let Some(Ok(body)) = crate::coplanar::glue(a, b)
{
let (ok, _) = check(&body);
if ok {
return Ok(Some(body));
if op == BoolOp::Union && a.solid.boundaries().len() == 1 {
let glued = if b.solid.boundaries().len() == 1 { crate::coplanar::glue(a, b) } else { crate::coplanar::glue_all_caps(a, b) };
if let Some(Ok(body)) = glued {
let (ok, _) = check(&body);
if ok {
return Ok(Some(body));
}
}
}
// Bores on a common cylinder (a pad's bore around the hole it is joined over, a disc filling
+115 -21
View File
@@ -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![] }
}
+72 -2
View File
@@ -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.
+117
View File
@@ -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);
}
}
+98 -6
View File
@@ -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;
+86
View File
@@ -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:?}");
}
}
+216
View File
@@ -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
}
+126
View File
@@ -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));
}
+4
View File
@@ -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;
+20
View File
@@ -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"
+11
View File
@@ -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));
}
+81
View File
@@ -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)
}
}
+220
View File
@@ -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);
}
}
+45 -5
View File
@@ -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();
+64 -5
View File
@@ -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(&param).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, &param);
}
// 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")] {
+395
View File
@@ -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(&region.outer).chain(&region.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((&region.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());
}
}
+1
View File
@@ -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)]
+45
View File
@@ -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};
+55
View File
@@ -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()))
}
+52 -3
View File
@@ -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));
+323 -74
View File
@@ -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");
}
}
}
}
+114 -5
View File
@@ -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"
}
},
{
+26
View File
@@ -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,
+7
View File
@@ -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.
+13
View File
@@ -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:
+71
View File
@@ -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.
![Model tab, floating](model-floating.png)
## 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.
![Model tab, compact](model-compact.png)
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

+4 -2
View File
@@ -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` | – |
+7
View File
@@ -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"}}
]
}
+44
View File
@@ -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
+7
View File
@@ -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
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more