mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52eb25311a | |||
| dd23be12d5 | |||
| ab22da1343 | |||
| 011ff24107 | |||
| 4a4eb59788 | |||
| 207c67d1da | |||
| ff5d96aaad | |||
| bc68e14c3e | |||
| 6d410c342c | |||
| dded27277f | |||
| 46462957e4 | |||
| a228a5830e | |||
| e4ff62c825 | |||
| f5a868efda | |||
| 8313c3468a | |||
| deb28dd721 | |||
| c25e101b77 | |||
| e766fb14fb | |||
| cac4bccea8 | |||
| 3762e15cd5 | |||
| b1e5a5559e | |||
| 9c7afb6076 | |||
| 3eeb20d2bd | |||
| a0610a6329 | |||
| 2c877b9f9d | |||
| f498d23a79 | |||
| 55bfeb71a7 | |||
| 8025966a35 | |||
| 1ee6611403 | |||
| 535a43ab3c | |||
| 2b8e85a7c6 | |||
| b7a6c68702 | |||
| 5ddb94b018 | |||
| d2ccb576dd | |||
| d818629f38 | |||
| 7cf8c783e6 | |||
| 1c18161920 | |||
| a4edea9ad2 | |||
| 4ebaca97e7 | |||
| 69b73af490 | |||
| 4e0601611c | |||
| 07708a4e2a | |||
| a4c4b411e5 | |||
| 5eb7640b54 | |||
| 003abc2149 | |||
| 136a3e9a2f | |||
| e70bd52409 | |||
| 132b63d24d | |||
| 72b7fc385b | |||
| 292747e50b | |||
| 2e4266cb0a | |||
| b685b7301f | |||
| 8f768b65c8 | |||
| 932efbf341 | |||
| b8d7b42808 | |||
| a8e8d82f90 | |||
| 33cd031701 | |||
| 6422ef84d5 | |||
| 7c9f8a5895 | |||
| 36b6496ad5 | |||
| 48986041ed | |||
| c39bd23d07 | |||
| a33c5a7861 | |||
| 5c95b0496d | |||
| 72aa77dabd | |||
| 243db6cb8f | |||
| 83c31d77c4 | |||
| ae6a33bf96 | |||
| 675c865431 | |||
| 2f31138f9d | |||
| 8c503ad1be | |||
| dc40f051b1 | |||
| 712b427a9c | |||
| e48e553758 | |||
| dc8edead22 | |||
| 2acc344aa4 | |||
| 487aa64e10 | |||
| 180e62c981 | |||
| 11a196e6f8 | |||
| a11446fd17 | |||
| 7a3221d2dd | |||
| e8776d8583 | |||
| cb857f1fa7 | |||
| e347f820e6 | |||
| 6597ffe072 | |||
| 4a70c03deb | |||
| f480898158 | |||
| 9be8bb5e6b | |||
| 843fa0dac6 | |||
| 2c924fd385 | |||
| 8be736c33e | |||
| f3494efbbb | |||
| 6fe16b790b | |||
| 979614e60f | |||
| 8a31738fe5 | |||
| f7343e9161 |
@@ -0,0 +1,14 @@
|
||||
# cargo-mutants (docs/mutation-testing.md). Mutants are judged by the black-box suites that will
|
||||
# outlive the kernel backend: the reference tests (tests/oracle, through the oracle_cases test)
|
||||
# and the pull-request sample of the robustness goldens (every 8th seed, as ci.yml). The
|
||||
# kernel's own unit tests are left out on purpose: a mutant only they catch is a behaviour the
|
||||
# backend-neutral suites would miss when the kernel is swapped.
|
||||
profile = "mutants"
|
||||
additional_cargo_test_args = [
|
||||
"--test", "oracle_cases", "--lib", "--", "--include-ignored",
|
||||
"oracle_suite",
|
||||
"robustness_goldens::seed_00", "robustness_goldens::seed_08", "robustness_goldens::seed_16",
|
||||
"robustness_goldens::seed_24", "robustness_goldens::seed_32", "robustness_goldens::seed_40",
|
||||
"robustness_goldens::seed_48", "robustness_goldens::seed_56", "robustness_goldens::seed_64",
|
||||
"robustness_goldens::seed_72", "robustness_goldens::seed_80", "robustness_goldens::seed_88",
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Bug report
|
||||
description: Something in SolidCraft does the wrong thing
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report this! Inside the app, **Report an Issue** fills most of this in for you, with the command, its settings and your environment.
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What did you expect, and what happened instead?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: |
|
||||
What you clicked or typed, in order. For a problem on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
|
||||
placeholder: |
|
||||
1. Sketch a circle on the XY plane
|
||||
2. Extrude it 10 mm
|
||||
3. Fillet the top edge with 6 mm
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: SolidCraft version and OS
|
||||
description: Help → About shows the version.
|
||||
placeholder: 0.15.0, Windows 11
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: media
|
||||
attributes:
|
||||
label: Screenshots or recording
|
||||
description: |
|
||||
For anything you can see in the app, a screenshot or short recording of a small example helps the most. Please leave out private designs and personal details.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Help reproduce and fix a SolidCraft problem
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Behavior
|
||||
|
||||
What did you expect, and what happened instead?
|
||||
|
||||
## Reproduction
|
||||
|
||||
Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data.
|
||||
|
||||
When the problem shows on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
|
||||
|
||||
## Screenshots of the behavior (when observable)
|
||||
|
||||
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
|
||||
|
||||
Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Blank issues stay on: the app's Report an Issue opens issues/new?title=…&body=… with the report
|
||||
# filled in (crates/ui-egui/src/issue_report.rs), which GitHub drops when blank issues are off.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Feature request
|
||||
description: Something SolidCraft should do
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: goal
|
||||
attributes:
|
||||
label: What are you trying to do
|
||||
description: The part or workflow you have in mind, and where SolidCraft gets in the way today.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: How you imagine it working
|
||||
description: Optional. Describe the behaviour in your own words; please don't paste another product's documentation.
|
||||
@@ -1,3 +1,4 @@
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- self-hosted-fast
|
||||
- self-hosted-light
|
||||
|
||||
@@ -15,3 +15,12 @@ Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavi
|
||||
## Integration status
|
||||
|
||||
Record the upstream commit and PR status checked before handoff, any conflicts resolved, and validation after integration. Keep up with upstream merges and monitor this PR while the task is active; rebase before pushing and never force-push.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] The area is open on the [integration map](https://github.com/tangency-labs/solidcraft/blob/main/ROADMAP.md#integration-map), or the approach was agreed in the linked issue.
|
||||
- [ ] Linked issue (`Fixes #…`), ideally one in a current milestone.
|
||||
- [ ] One topic, under about 1,000 changed lines (over about 2,000 only with an agreed issue).
|
||||
- [ ] Tests added or updated for the change.
|
||||
- [ ] No GPL/LGPL/AGPL code, dependencies or required programs; new dependencies are named above with their licence.
|
||||
- [ ] If AI tools helped, I have read and understood every line and can explain it.
|
||||
|
||||
+160
-40
@@ -10,18 +10,30 @@
|
||||
# and app startup) run on pull requests only when what they test changes (the GPU crate, the app,
|
||||
# the viewport's GPU glue, the graphics dependencies, their scripts or this file); main and
|
||||
# nightly always run them. Only main saves the build caches; pull requests restore them.
|
||||
# The merge queue runs the full suite on exactly the commit it then puts on main, so a push to
|
||||
# main that a passing merge-queue run already tested runs only the repository checks; a push
|
||||
# that skipped the queue still runs everything. Pull requests run the reference tests when the
|
||||
# command line (or anything it depends on) changes, and doc tests for the affected packages.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
# Pushes to main and merge-queue groups use both pools: Clippy, the doc tests and test shards
|
||||
# 1-2 run on the self-hosted runner (`self-hosted-fast`, warm build directories via
|
||||
# scripts/ci/warm-target.sh), everything else on GitHub's runners. The test archive is built on
|
||||
# GitHub's runners: the self-hosted machine has a newer glibc, so its binaries wouldn't start
|
||||
# on ubuntu-24.04, while the archive runs there unchanged (same /home/runner/work checkout
|
||||
# path). Pull requests, the nightly run and Run workflow use GitHub's runners only; the
|
||||
# self-hosted runner refuses any job that isn't a push to main or a merge-queue group.
|
||||
# Trusted runs use both pools: pushes to main, merge-queue groups and pull requests from a branch
|
||||
# of this repository (people with write access; fork pull requests never run on this machine).
|
||||
# There, Select checks and ci run on the self-hosted light runner (`self-hosted-light`, quick
|
||||
# jobs, so they never wait behind a build), and the repository checks, Clippy, the doc tests,
|
||||
# the reference tests and all four test shards on the self-hosted runners (`self-hosted-fast`, warm
|
||||
# build directories via scripts/ci/warm-target.sh). Fork PRs' Linux jobs run on the isolated, ephemeral
|
||||
# `self-hosted-untrusted` runners (no warm caches) when the PR number ends in 7, 8 or 9 (the rest of
|
||||
# the fork PRs, and everything else, run on GitHub's runners, which have far more free slots).
|
||||
# The test archive is always built on GitHub's runners: the self-hosted machine has a newer
|
||||
# glibc, so its binaries wouldn't start on ubuntu-24.04, while the archive runs there unchanged
|
||||
# (same /home/runner/work checkout path). Fork pull requests, the nightly run and Run workflow
|
||||
# use GitHub's runners only; the self-hosted runners refuse any other job.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# Draft pull requests run no CI (see `changes`, `checks` and `ci`): it starts when the PR is
|
||||
# marked ready for review, and on every push after that.
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
# The merge queue: each queued group runs the full suite before it lands on main.
|
||||
merge_group:
|
||||
push:
|
||||
@@ -63,13 +75,26 @@ jobs:
|
||||
|
||||
changes:
|
||||
name: Select checks
|
||||
runs-on: ubuntu-24.04
|
||||
# Not on a draft: every other check needs this one, so they skip too.
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
# Trusted runs (see the top of this file) use the self-hosted runners.
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || '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 }}
|
||||
goldens: ${{ steps.paths.outputs.goldens }}
|
||||
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 +105,31 @@ 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
|
||||
goldens=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
|
||||
@@ -94,6 +137,10 @@ jobs:
|
||||
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths"
|
||||
while IFS= read -r -d '' path; do
|
||||
case "$path" in crates/kernel/*|vendor/truck-*) kernel=true ;; esac
|
||||
# What can change a robustness golden: the kernel and the crates a design is built with.
|
||||
case "$path" in
|
||||
crates/kernel/*|crates/engine/*|crates/doc/*|crates/sketch/*|crates/geom/*|vendor/truck-*) goldens=true ;;
|
||||
esac
|
||||
# What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and
|
||||
# its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts.
|
||||
case "$path" in
|
||||
@@ -104,6 +151,8 @@ jobs:
|
||||
.github/workflows/ci.yml) native=true ;;
|
||||
*.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;;
|
||||
docs/*|*.md|.github/*|packaging/*|LICENSE*|NOTICE|THIRD-PARTY-LICENSES.txt) ;;
|
||||
# The GitHub bots' scripts: no Rust, tested in this job (Claim and triage bot tests).
|
||||
scripts/claim/*|scripts/triage/*) ;;
|
||||
*) native=true ;;
|
||||
esac
|
||||
done < "$RUNNER_TEMP/ci-paths"
|
||||
@@ -120,25 +169,47 @@ jobs:
|
||||
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
|
||||
# Code changed but nothing was selected: be safe and test everything.
|
||||
if [ "$native" = true ] && [ "$full" != true ] && [ -z "$packages" ]; then full=true; fi
|
||||
# The goldens are solidcraft-engine tests: its tests must be in the archive.
|
||||
if [ "$goldens" = true ] && [ "$full" != true ]; then
|
||||
case " $packages " in *" solidcraft-engine "*) ;; *) packages="$packages solidcraft-engine" ;; esac
|
||||
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 "goldens=$goldens"
|
||||
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
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh checks
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: repo-checks
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -152,29 +223,34 @@ jobs:
|
||||
run: cargo xtask licences --check
|
||||
- name: Dependency layers
|
||||
run: cargo xtask layers
|
||||
- name: Sorted lists
|
||||
run: cargo xtask lists
|
||||
- name: Design format pending changes
|
||||
run: bash scripts/test-format-pending.sh
|
||||
- name: Web app offline use and updates (headless Chrome)
|
||||
run: cargo xtask web-offline-test
|
||||
- name: Affected-package selection self-test
|
||||
run: python3 -I scripts/ci/test_affected.py
|
||||
- name: Claim bot tests
|
||||
run: node --test scripts/claim/*.test.js
|
||||
- name: Claim and triage bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
|
||||
|
||||
clippy:
|
||||
name: Native Linux Clippy
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted'
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh clippy
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
# The self-hosted runner keeps its build directory and Cargo home instead.
|
||||
- if: runner.environment == 'github-hosted'
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-clippy
|
||||
@@ -193,8 +269,9 @@ jobs:
|
||||
name: Build tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04' }}
|
||||
# Fork builds compile inside gVisor on shared boxes; give them room to finish and upload.
|
||||
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 90 || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
@@ -202,6 +279,8 @@ jobs:
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
@@ -236,12 +315,13 @@ jobs:
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: [changes, build-tests]
|
||||
runs-on: ${{ ((github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && matrix.shard <= 2) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
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 +346,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 +358,22 @@ 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'
|
||||
--profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${all[@]}"
|
||||
- name: Robustness goldens (pull requests that change geometry)
|
||||
# Every seed of tests/robustness/goldens.json (each seed is a test), split over the shards;
|
||||
# pushes to main and the nightly run them in the step above.
|
||||
if: github.event_name == 'pull_request' && needs.changes.outputs.goldens == 'true'
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
|
||||
run: |
|
||||
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)$/)'
|
||||
if ! cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --ignore-default-filter --partition "slice:$SHARD/$SHARDS" --no-tests=pass \
|
||||
-E 'package(solidcraft-engine) & test(/^tests::robustness_goldens::/)'; then
|
||||
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
|
||||
echo "::error::ROBUSTNESS_GOLDENS_BLESS=\"why it changed\" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens:: (see docs/robustness.md)"
|
||||
exit 1
|
||||
fi
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -392,7 +478,7 @@ jobs:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -401,12 +487,15 @@ jobs:
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted'
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh doc-tests
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# The self-hosted runner keeps its build directory and Cargo home instead.
|
||||
- if: runner.environment == 'github-hosted'
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: doc-tests
|
||||
@@ -416,13 +505,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 && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -431,8 +540,16 @@ jobs:
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh oracle
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: oracle
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -443,7 +560,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
|
||||
@@ -511,8 +628,8 @@ jobs:
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
@@ -520,6 +637,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 +653,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
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Sending a change"), and a daily sweep that
|
||||
# frees claims nobody is working on. The logic is in scripts/claim/claim.js (tested in ci.yml).
|
||||
# frees claims nobody is working on. A pull request that references an issue (`<summary> (#N)`,
|
||||
# `Fixes #N`) claims it too. The logic is in scripts/claim/claim.js (tested in ci.yml).
|
||||
name: Claim
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# pull_request_target so PRs from forks can label and assign. It runs the base branch's
|
||||
# scripts/claim and only reads the PR's title, body and author; the PR's code is never checked out.
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
schedule:
|
||||
- cron: '17 5 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -16,7 +21,7 @@ permissions:
|
||||
|
||||
# One run at a time per issue, so two simultaneous /claim comments can't both win.
|
||||
concurrency:
|
||||
group: claim-${{ github.event.issue.number || 'sweep' }}
|
||||
group: claim-${{ github.event.issue.number || github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -38,6 +43,9 @@ jobs:
|
||||
const claim = require('./scripts/claim/claim.js');
|
||||
if (context.eventName === 'issue_comment') {
|
||||
await claim.handleComment({ github, context });
|
||||
} else if (context.eventName === 'pull_request_target') {
|
||||
const claimed = await claim.handlePull({ github, context });
|
||||
core.info(`Claimed: ${claimed.join(', ') || 'none'}`);
|
||||
} else {
|
||||
const released = await claim.sweep({ github, context });
|
||||
core.info(`Released: ${released.join(', ') || 'none'}`);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# The web app as a container image on GitHub's container registry, for self-hosting
|
||||
# (packaging/docker; the book's "Self-hosting with Docker"):
|
||||
#
|
||||
# ghcr.io/<owner>/solidcraft-web:latest the latest published release
|
||||
# ghcr.io/<owner>/solidcraft-web:0.14.0 a release (also 0.14); pre-releases get only their version
|
||||
# ghcr.io/<owner>/solidcraft-web:edge main
|
||||
#
|
||||
# For linux/amd64 and linux/arm64. The site is built here, as Pages and Release build it, and
|
||||
# passed to the Dockerfile as its `site` stage. Pull requests that touch the image build and
|
||||
# smoke-test it without publishing. Releases published by an agent or maintainer trigger this;
|
||||
# the nightly pre-releases (made with GITHUB_TOKEN) do not.
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
pull_request:
|
||||
paths: ["packaging/docker/**", ".github/workflows/docker.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
TRUNK_VERSION: v0.21.14
|
||||
|
||||
jobs:
|
||||
image:
|
||||
if: ${{ !github.event.repository.private }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: docker-web
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Install trunk
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Build the web app
|
||||
working-directory: apps/solidcraft-web
|
||||
run: trunk build --release
|
||||
- name: Image name
|
||||
id: image
|
||||
# Registry names are lower case; a fork's owner may not be.
|
||||
run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/solidcraft-web" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
id: meta
|
||||
with:
|
||||
images: ${{ steps.image.outputs.name }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=edge,branch=main
|
||||
type=ref,event=pr
|
||||
labels: |
|
||||
org.opencontainers.image.title=SolidCraft (web)
|
||||
org.opencontainers.image.description=SolidCraft, open-source parametric CAD, in the browser: the web app served by nginx on port 8080
|
||||
org.opencontainers.image.licenses=MIT OR Apache-2.0
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
- name: Build for the smoke test
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: packaging/docker/Dockerfile
|
||||
build-contexts: site=dist/web
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
tags: solidcraft-web:test
|
||||
- name: Smoke test
|
||||
run: bash packaging/docker/smoke-test.sh solidcraft-web:test
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and publish
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: packaging/docker/Dockerfile
|
||||
build-contexts: site=dist/web
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
@@ -1,9 +1,10 @@
|
||||
# Files many pull requests add to are kept so that additions don't conflict: one file per change
|
||||
# or one sorted line per item (CONTRIBUTING.md, "Lists many changes add to"). Fast, no build.
|
||||
# Pull requests get the same checks in CI's "Format and repository checks" job (one runner slot
|
||||
# per PR instead of three); this copy keeps main honest.
|
||||
name: Lists
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
branches: [main]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/**"]
|
||||
pull_request:
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/**"]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/platforms/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Labels new issues and PRs from fixed rules, `/triage` for maintainers, asks short bug reports
|
||||
# for reproduction steps (`needs-info`, closed after 14 days unanswered), and a manual backfill
|
||||
# (CONTRIBUTING.md, "Labels and triage"). The logic is in scripts/triage/ (tested in ci.yml).
|
||||
name: Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled, edited]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
|
||||
# scripts/triage and only lists the PR's changed paths; the PR's code is never checked out.
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
schedule:
|
||||
- cron: '41 5 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Only report what would be labelled'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read # to check out scripts/triage
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: triage-${{ github.event.issue.number || github.event.pull_request.number || 'repo' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
# Filtered here so the common events (every comment, every label) start no runner.
|
||||
if: >-
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.comment.user.type != 'Bot' && startsWith(github.event.comment.body, '/triage')) ||
|
||||
((github.event_name == 'issue_comment' || github.event.action == 'edited') &&
|
||||
contains(github.event.issue.labels.*.name, 'needs-info') &&
|
||||
github.event.sender.login == github.event.issue.user.login) ||
|
||||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'priority:') &&
|
||||
contains(github.event.issue.labels.*.name, 'needs-triage')) ||
|
||||
(github.event_name != 'issue_comment' && github.event.action != 'labeled' && github.event.action != 'edited')
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/triage
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
with:
|
||||
script: |
|
||||
const triage = require('./scripts/triage/triage.js');
|
||||
const info = require('./scripts/triage/needs-info.js');
|
||||
const event = context.eventName;
|
||||
const action = context.payload.action;
|
||||
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
||||
const th = (...names) => names.map((data) => ({ data, header: true }));
|
||||
if (event === 'issue_comment') {
|
||||
core.info(await triage.handleComment({ github, context }));
|
||||
await info.handleReply({ github, context });
|
||||
} else if (event === 'issues' && action === 'labeled') {
|
||||
await triage.handleLabeled({ github, context });
|
||||
} else if (event === 'issues' && action === 'edited') {
|
||||
await info.handleReply({ github, context });
|
||||
} else if (event === 'schedule') {
|
||||
const closed = await info.sweep({ github, context });
|
||||
core.info(`Closed for missing information: ${closed.join(', ') || 'none'}`);
|
||||
} else if (event === 'workflow_dispatch') {
|
||||
const dryRun = process.env.DRY_RUN !== 'false';
|
||||
const plan = await triage.backfill({ github, context, dryRun });
|
||||
const asks = await info.backfill({ github, context, dryRun });
|
||||
await core.summary
|
||||
.addHeading(dryRun ? `Backfill dry run: ${plan.length} unlabelled` : `Backfill: ${plan.length} labelled`)
|
||||
.addTable([th('Item', 'Kind', 'Labels', 'Title'), ...plan.map((p) => [`#${p.number}`, p.pull ? 'PR' : 'issue', esc(p.labels.join(', ') || '—'), esc(p.title)])])
|
||||
.addHeading(dryRun ? `needs-info dry run: ${asks.length} would be asked` : `needs-info: ${asks.length} asked`)
|
||||
.addTable([th('Issue', 'Asks', 'Title'), ...asks.map((p) => [`#${p.number}`, esc(p.asks.join(', ')), esc(p.title)])])
|
||||
.write();
|
||||
} else {
|
||||
core.info(`Labelled: ${(await triage.handleOpened({ github, context })).join(', ') || 'nothing'}`);
|
||||
if (event === 'issues') await info.handleOpened({ github, context });
|
||||
}
|
||||
@@ -7,3 +7,6 @@
|
||||
/.claude/worktrees/
|
||||
/target-snapshot
|
||||
__pycache__/
|
||||
|
||||
# cargo-mutants output (docs/mutation-testing.md)
|
||||
mutants.out*/
|
||||
|
||||
@@ -132,9 +132,13 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. Don't claim by commenting or by editing files.
|
||||
issue wins**; if yours lost, close it. A comment or file edit alone does not replace the draft PR.
|
||||
- After opening the draft PR, comment `/claim` on the issue so the bot records the claimant and
|
||||
adds the `in progress` label. **Do both:** the draft PR establishes ownership and `/claim` marks
|
||||
the issue as taken. Reference `#N` in the PR so the bot can find it.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
|
||||
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
|
||||
and wait while it is over 24; build with `nice`.
|
||||
|
||||
|
||||
+16
-1
@@ -32,6 +32,13 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/rover-build.mp4 | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/model-floating.png | SolidCraft contributors | original UX mockup (Model tab, floating layout) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/model-compact.png | SolidCraft contributors | original UX mockup (Model tab, compact docked layout) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/sketch.png | SolidCraft contributors | original UX mockup (Sketch tab) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/sheet-metal.png | SolidCraft contributors | original UX mockup (Sheet Metal contextual tab) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/start-page.png | SolidCraft contributors | original UX mockup (start page (File view)) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/workspaces.png | SolidCraft contributors | original UX mockup (workspace switcher) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| docs/design/ux-redesign/command-map.png | SolidCraft contributors | original UX mockup (ribbon command map) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
|
||||
| assets/branding/web/source/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sample-plate.png | SolidCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/bracket.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
@@ -86,8 +93,10 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/gl.json | Pablo López (pablopeza) | Galician translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/pl.json | cyryllo | Polish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/pt-BR.json | DuBearte | Brazilian Portuguese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/tr.json | macrulz54 | Turkish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
@@ -103,6 +112,12 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-dark.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-dark-hard.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-dark-soft.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-light-hard.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-light-soft.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/web/loading-logo.svg | SolidCraft contributors | Animation of the SolidCraft mark, built from the paths and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg; original animation | MIT OR Apache-2.0 |
|
||||
| assets/styles/web/loading.css | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| crates/io/src/native_cad/fixtures/block.FCStd | SolidCraft contributors | original 10 × 20 × 30 mm test box saved by FreeCAD 1.1.4 run as an external program (`make_block_fcstd.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
|
||||
@@ -455,7 +470,7 @@ copied into SolidCraft.
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
|
||||
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters (vendor/README.md) |
|
||||
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters; surface second derivatives (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
## Vendored renderer compatibility fix
|
||||
|
||||
@@ -27,8 +27,10 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
|
||||
## Never crash
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
|
||||
(which denies it instead), have no safe API: two functions in
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
|
||||
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
|
||||
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
|
||||
and calls through its function pointers), two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
|
||||
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers) and the Windows-only
|
||||
@@ -127,7 +129,8 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. Don't claim by commenting or by editing files.
|
||||
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
|
||||
labels the issue `in progress` (or says on your PR who holds it). Don't claim by editing files.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
|
||||
|
||||
+104
-32
@@ -10,7 +10,10 @@ 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).
|
||||
Before choosing a task, check the [command inventory](docs/features.md) and
|
||||
[roadmap](ROADMAP.md) for implemented capabilities and current priorities.
|
||||
|
||||
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 +113,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.
|
||||
@@ -126,6 +130,10 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
`navigation::handle`; never read drags, the wheel or pinch to move the camera yourself. The
|
||||
`hand_rolled_camera_input_is_rejected` test fails if a page does.
|
||||
|
||||
Labels, parameter docs, messages and docs use the terms in the
|
||||
[glossary](https://docs.solidcraft.io/reference/glossary.html): one term for each idea. A change that adds a new
|
||||
idea adds its term there.
|
||||
|
||||
[AGENTS.md](AGENTS.md) has the long form of these rules. It's written for AI agents, but it's
|
||||
the most complete description of the house style.
|
||||
|
||||
@@ -137,6 +145,12 @@ the most complete description of the house style.
|
||||
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
|
||||
| Reference tests | `cargo xtask oracle` | `tests/oracle/` (see below) |
|
||||
|
||||
A geometry change that changes the robustness goldens re-records them in the same pull request:
|
||||
`ROBUSTNESS_GOLDENS_BLESS="why it changed" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens::`
|
||||
(see [docs/robustness.md](docs/robustness.md)); CI runs every seed on pull requests that touch geometry.
|
||||
[docs/mutation-testing.md](docs/mutation-testing.md) shows how to find kernel behaviour these
|
||||
black-box suites miss.
|
||||
|
||||
**UI scenarios** drive the real app headless: start a tool, click a plane or a sketch point,
|
||||
press keys, then assert on the result.
|
||||
|
||||
@@ -166,21 +180,24 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
|
||||
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
|
||||
depend on wall-clock timing.
|
||||
|
||||
**PRs test what they touch; main and nightly test everything.** On a pull request, CI maps the
|
||||
changed files to workspace packages and runs Clippy, the test build and the tests for those and
|
||||
the packages that depend on them (`scripts/ci/affected.py`, self-tested in the `checks` job). A
|
||||
change to `Cargo.lock`, the root `Cargo.toml`, `.cargo/`, `.config/`, `.github/`, `vendor/`,
|
||||
`tests/`, `scripts/` or any file outside a package runs the whole workspace, as does every push
|
||||
to `main`, the nightly run and Run workflow.
|
||||
**Ready pull requests test what they touch.** CI maps changed files to workspace packages and
|
||||
runs Clippy, the test build and tests for those and their dependents. The
|
||||
[package selector](scripts/ci/affected.py) and [CI workflow](.github/workflows/ci.yml) define
|
||||
which changes require native checks; documentation, packaging and bot-script-only PRs can run
|
||||
repository checks only. Workspace-wide changes, merge-queue groups, nightly runs and Run
|
||||
workflow select the full workspace. A push to `main` reuses a successful merge-queue run for
|
||||
the same commit; otherwise it runs the full suite.
|
||||
|
||||
**CI shards the tests.** One job builds them into a cargo-nextest archive and four jobs run a
|
||||
quarter each, so every test runs in its own process: don't rely on state left by another test.
|
||||
Pull requests skip a few heavy tests (one to four minutes each), which run on every push to
|
||||
`main` and nightly; check the `main` run after merging. They are listed in
|
||||
**CI shards the tests.** One job builds them into a cargo-nextest archive; two shards run on
|
||||
pull requests and four during full validation. Every test runs in its own process: don't rely
|
||||
on state left by another test.
|
||||
Pull requests skip a few heavy tests (one to four minutes each), which run in full validation
|
||||
(the merge queue, nightly, manual runs and pushes to `main` not already tested by the queue).
|
||||
Check that validation after merging. They are listed in
|
||||
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
|
||||
|
||||
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
|
||||
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests that touch `crates/kernel` run every 8th),
|
||||
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all seeds),
|
||||
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
|
||||
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
|
||||
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests
|
||||
@@ -193,15 +210,16 @@ corpus of designs against a checked-in ratchet ([docs/action-matrix.md](docs/act
|
||||
A pull request that makes an action pass should record it with `cargo xtask action-matrix --bless`;
|
||||
one that makes a passing action fail fails the sample every CI run checks.
|
||||
|
||||
**The reference tests** are 76 parts in [`tests/oracle/`](tests/oracle/), each a `recipe.json`
|
||||
**The reference tests** live in [`tests/oracle/`](tests/oracle/), each a `recipe.json`
|
||||
in our own format with an `expected.json` beside it. SolidCraft rebuilds the recipe and compares
|
||||
body count, volume, area, centre of mass and bounding box (and, where there is a `reference.stl`,
|
||||
the surface itself). Expected values are either worked out in closed form
|
||||
(`tools/oracle-analytic/`) or computed by building the same recipe in FreeCAD, run as a separate
|
||||
program (`tools/freecad-oracle/`); each file says which. [docs/oracle.md](docs/oracle.md) has
|
||||
the current results, and CI runs `cargo xtask oracle` on every pull request. A case that fails
|
||||
because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`); never
|
||||
change an expected value to match SolidCraft's output.
|
||||
program (`tools/freecad-oracle/`); each file says which. The generated
|
||||
[reference test report](docs/oracle.md) has the current case count and results. The reference-test
|
||||
CI job runs `cargo xtask oracle` during full validation; pull-request runs skip it. A case that
|
||||
fails because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`);
|
||||
never change an expected value to match SolidCraft's output.
|
||||
|
||||
## Changing the design file format
|
||||
|
||||
@@ -246,7 +264,24 @@ and a fresh CI run:
|
||||
features`). Don't regenerate or edit them in a pull request.
|
||||
- **Design format**: one file per change, never an edit to a shared number (above).
|
||||
|
||||
## Sending a change
|
||||
## Before you start
|
||||
|
||||
- **Check the [integration map](ROADMAP.md#integration-map).** It says which areas are open
|
||||
for pull requests now, which need a conversation first and which are deferred. The CAD core
|
||||
(sketch, solid modelling, assemblies, files) comes first. For a "talk first" area, open an
|
||||
issue (or comment on its RFC) and agree the approach before you write code.
|
||||
- **Keep pull requests reviewable.** Aim for under about 1,000 changed lines on one topic.
|
||||
Anything over about 2,000 lines needs an issue where a maintainer has agreed the plan first.
|
||||
Stacked pull requests are fine, as long as each one makes sense and can be reviewed on its own.
|
||||
Generated files, test data and goldens don't count towards the size, but say what they are.
|
||||
- **AI-assisted code is welcome.** You must understand every line you send and be able to
|
||||
explain it in review. Large amounts of generated code nobody has reviewed will be closed.
|
||||
- **Licences: no GPL, LGPL or AGPL.** That covers code, dependencies and programs SolidCraft
|
||||
needs at run time (for example FreeCAD or OpenCASCADE), as well as code ported or translated
|
||||
from them. The only exception is test tooling you run by hand as a separate program, like the
|
||||
[FreeCAD reference-value scripts](tools/freecad-oracle/README.md). See rule 2 above.
|
||||
|
||||
## Sending a pull request
|
||||
|
||||
1. Work is tracked in [GitHub issues](https://github.com/tangency-labs/solidcraft/issues), labelled
|
||||
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). For anything
|
||||
@@ -257,6 +292,10 @@ and a fresh CI run:
|
||||
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
|
||||
- **The lowest-numbered open PR for an issue wins.** If yours lost, close it (or offer to help
|
||||
on the other one).
|
||||
- The bot records the claim when the PR is opened: it assigns you, adds `in progress` and
|
||||
says so on the issue. It reads `#N` in the PR title and `Fixes #N` in the body. If someone
|
||||
already holds the issue, or an older open PR references it, it says so on your PR instead.
|
||||
- Want to stake a claim before you have a branch? Comment `/claim` on the issue (below).
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim.
|
||||
- Mark the PR ready for review when it is done.
|
||||
@@ -266,12 +305,28 @@ and a fresh CI run:
|
||||
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
|
||||
the expected ones).
|
||||
|
||||
Comment `/claim` on an issue to take it: you are assigned, it gets the `in progress` label, and
|
||||
the bot replies with the deadline. Open a draft PR referencing the issue within 72 hours, or the
|
||||
claim lapses and the issue is freed again; a claim with a PR is freed after 14 days without
|
||||
pushes. `/unclaim` releases it early (only you or a maintainer can). Issues a maintainer has
|
||||
Commenting `/claim` assigns you, adds the `in progress` label, and replies with its automatic cleanup
|
||||
deadline. It frees a claim with no linked PR after 72 hours, or a claim with a PR after 14 days
|
||||
without pushes. These automatic cleanup deadlines are separate from the daily progress and
|
||||
72-hour takeover policy above. A draft PR that references the issue claims it the same way.
|
||||
`/unclaim` releases the bot claim early (only you or a maintainer can). Issues a maintainer has
|
||||
taken are never freed automatically.
|
||||
|
||||
### Review, CI and milestones
|
||||
|
||||
- **Quick checks on every push.** Formatting, the repository checks and a quick compile run on
|
||||
every pull request.
|
||||
- **Full CI after review.** Once a maintainer has looked at the pull request they add the
|
||||
`approved-for-ci` label, which runs the full suite (build, tests, goldens, Clippy). This is
|
||||
coming with [#2689](https://github.com/tangency-labs/solidcraft/pull/2689); until it lands,
|
||||
ready pull requests run the full suite straight away.
|
||||
- **Merges go through the merge queue**, which runs the full suite once more on top of the
|
||||
latest `main` before the change lands.
|
||||
- **Milestones set the order.** The [milestones](https://github.com/tangency-labs/solidcraft/milestones)
|
||||
are the current sprint goals, and pull requests for them are reviewed first. When you can,
|
||||
link your pull request to an issue in one of them. Other pull requests are still welcome;
|
||||
they may just wait longer.
|
||||
|
||||
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
|
||||
include actual descriptive screenshots or a short recording in the issue or PR. This evidence
|
||||
is required for changes or bugs that can be demonstrated in the app, including kernel,
|
||||
@@ -298,6 +353,27 @@ owning one area, with a human setting direction and reporting bugs. Their workin
|
||||
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
|
||||
long as the change meets the rules above.
|
||||
|
||||
## Labels and triage
|
||||
|
||||
The triage bot labels every new issue and PR (`.github/workflows/triage.yml`). It only adds labels;
|
||||
correct them freely and it won't put them back.
|
||||
|
||||
- **PRs** get `area:*` from the paths they change (`crates/kernel` and `crates/geom` →
|
||||
`area:kernel`, `crates/ui-egui` and the apps → `area:ui`, `.github/`, `scripts/`, `docs/` →
|
||||
`area:infra`, and so on).
|
||||
- **Issues** get `bug` (in-app reports and the bug template), `enhancement` (feature requests),
|
||||
`priority:P1` for crashes, and an `area:*` from the title or the reported command. Until it has
|
||||
a priority, an issue carries `needs-triage`; setting a priority removes it.
|
||||
- **Maintainers** comment `/triage area:kernel P1` (any existing label; `P0`–`P3` for a
|
||||
priority, which replaces the old one; `-label` removes one) to sort an issue or PR.
|
||||
- **Short bug reports without steps to reproduce** get `needs-info` and one comment asking for
|
||||
the steps (and the version, when it's missing). An app report that arrived empty is asked to
|
||||
paste the report the app copied to the clipboard. The reporter's reply, or an edit that adds the
|
||||
steps, removes the label; an issue that stays in `needs-info` for 14 days is closed, and can be
|
||||
reopened any time. Feature requests, app reports with their details, and maintainers' issues
|
||||
are never asked.
|
||||
- `needs-triage` is the queue: [open issues with it](https://github.com/tangency-labs/solidcraft/issues?q=is%3Aopen+label%3Aneeds-triage).
|
||||
|
||||
## Where help is wanted most
|
||||
|
||||
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
|
||||
@@ -312,13 +388,9 @@ In order of how much they'd help users:
|
||||
faces, and fillets that consume neighbouring faces.
|
||||
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
|
||||
through without a kernel error. Each failing seed is a reproducible bug.
|
||||
3. **Y-up / Z-up.** SolidCraft is Z-up only, so most STEP files (from SolidWorks, Onshape, or
|
||||
Fusion's default) open lying on their backs. Fusion solves this with a "default modeling
|
||||
orientation" preference.
|
||||
4. **Imported appearances.** Colours from STEP files are lost when a feature changes the
|
||||
imported body. They should become design appearances on import.
|
||||
5. **Drawings, CAM and mesh tools.** These are out of scope for now (see
|
||||
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
|
||||
3. **Drawings, CAM and mesh tools.** These wait until the CAD core is solid. The
|
||||
[integration map](ROADMAP.md#integration-map) says which are "talk first" and which are
|
||||
deferred; please start with an issue rather than code.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
Generated
+51
-16
@@ -865,6 +865,12 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -1665,6 +1671,16 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
@@ -2077,6 +2093,9 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
@@ -2085,6 +2104,16 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
@@ -4049,7 +4078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4069,6 +4098,7 @@ dependencies = [
|
||||
"ron",
|
||||
"rustls",
|
||||
"serde_json",
|
||||
"solidcraft-3dx-client",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-space-mouse",
|
||||
"solidcraft-ui-egui",
|
||||
@@ -4077,9 +4107,13 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.16.1"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -4090,7 +4124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4099,7 +4133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4116,7 +4150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4133,7 +4167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4142,7 +4176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4159,7 +4193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4179,7 +4213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4199,7 +4233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4208,7 +4242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4222,7 +4256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4233,12 +4267,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
@@ -4261,7 +4296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5791,7 +5826,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
+8
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.16.1"
|
||||
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"
|
||||
@@ -141,3 +142,9 @@ matext4cgmath = { path = "vendor/matext4cgmath" }
|
||||
|
||||
# Keep OpenGL pipelines, resolve textures and CAD callbacks at the same safe sample count.
|
||||
egui-wgpu = { path = "vendor/egui-wgpu" }
|
||||
|
||||
# Mutation testing (cargo-mutants, docs/mutation-testing.md): the ci profile with incremental
|
||||
# builds, since every mutant rebuilds the crates above the one it changes.
|
||||
[profile.mutants]
|
||||
inherits = "ci"
|
||||
incremental = true
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<a href="https://solidcraft.io/download">Download</a> ·
|
||||
<a href="https://solidcraft.io/app">Try it in the browser</a> ·
|
||||
<a href="https://docs.solidcraft.io/">Documentation</a> ·
|
||||
<a href="https://solidcraft.io/discord">Discord</a> ·
|
||||
<a href="https://discord.gg/FtU4NQNVDH">Discord</a> ·
|
||||
<a href="https://github.com/bherbruck/solidcore">SolidCore</a>
|
||||
</p>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<a href="https://github.com/tangency-labs/solidcraft/releases/latest"><img src="https://img.shields.io/github/v/release/tangency-labs/solidcraft?label=release" alt="Latest release" /></a>
|
||||
<a href="https://github.com/tangency-labs/solidcraft/releases"><img src="https://img.shields.io/github/downloads/tangency-labs/solidcraft/total?label=downloads" alt="Downloads" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/tangency-labs/solidcraft" alt="Licence" /></a>
|
||||
<a href="https://solidcraft.io/discord"><img src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://discord.gg/FtU4NQNVDH"><img src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://remade.wiki/projects/solidcraft/"><img src="https://remade.wiki/badges/listed-on-remade-classic.svg" alt="Listed on Remade" /></a>
|
||||
<a href="https://github.com/tangency-labs/solidcraft/stargazers"><img src="https://img.shields.io/github/stars/tangency-labs/solidcraft?style=flat" alt="Stars" /></a>
|
||||
</p>
|
||||
@@ -63,6 +63,10 @@
|
||||
The downloads aren't signed yet. On Windows, if SmartScreen warns you, choose **More info**, then
|
||||
**Run anyway**. On macOS, the first time, right-click SolidCraft in Applications and choose **Open**.
|
||||
|
||||
**On SolidCraft 0.11 to 0.14?** Its **Update now** can't install 0.15.0 or later, because the
|
||||
releases moved to a new address. Download the new version once from the links above; after that,
|
||||
updating from inside SolidCraft works again ([details](https://docs.solidcraft.io/guide/updates.html#updating-from-011-to-014)).
|
||||
|
||||
Older versions, checksums and other formats are on the [releases page](https://github.com/tangency-labs/solidcraft/releases).
|
||||
Install details, upgrading from earlier builds and building from source are in
|
||||
[Getting started](https://docs.solidcraft.io/guide/getting-started.html).
|
||||
@@ -90,7 +94,7 @@ Install details, upgrading from earlier builds and building from source are in
|
||||
**Documentation:** the [SolidCraft book](https://docs.solidcraft.io/) (user guide,
|
||||
developer guide and command reference; sources in [docs/book](docs/book)).
|
||||
|
||||
**Community:** chat with users and contributors on [Discord](https://solidcraft.io/discord).
|
||||
**Community:** chat with users and contributors on [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
**Status: early but usable for simple parts.** 75 of 76 public reference parts
|
||||
match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box
|
||||
@@ -215,7 +219,7 @@ unaccepted artwork Open; run `cargo xtask icons` to check the collection.
|
||||
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
On Windows and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis
|
||||
On Windows, macOS and Linux, SolidCraft detects a connected 3D mouse through its HID multi-axis
|
||||
controller interface: a 3Dconnexion SpaceNavigator or SpaceMouse, or a homemade device that
|
||||
declares itself one. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
|
||||
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
|
||||
@@ -253,6 +257,13 @@ lays them out: translation and rotation in separate reports (3Dconnexion's wired
|
||||
in one (its wireless ones and most homemade devices), after the buttons, or narrower than 16 bits.
|
||||
Each axis is scaled from the range the descriptor declares.
|
||||
|
||||
On macOS SolidCraft talks to the 3Dconnexion driver (3DxWare) when it is installed, through the
|
||||
driver's client API (`crates/threedx-client`; the framework is loaded at run time, never bundled).
|
||||
The driver keeps the device to itself, and sends it only to a program it can identify: SolidCraft
|
||||
as an `.app` (the packaged app). Without the driver, or with its helper not running, SolidCraft reads the device through IOKit like on Windows.
|
||||
|
||||
SolidCraft does not follow 3DxWare's profile on macOS. Its default is 3DxWare's object mode.
|
||||
|
||||
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
|
||||
@@ -278,8 +289,8 @@ A script is a JSON list of commands, the same ones the UI runs:
|
||||
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
|
||||
started with `solidcraft --control PORT`, so you can watch the agent model. Tools:
|
||||
`list_commands`, `execute`, `batch`, `inspect_design`, `measure`, `body_topology`,
|
||||
`set_parameter`, `screenshot` (returns the image), `export`, `undo`, `redo`, `new_design`, `open`,
|
||||
`save`. Tool calls reach files only in the open design's folder (when it lies inside your home
|
||||
`set_parameter`, `screenshot` (returns the image), `export`, `crash_reports`, `undo`, `redo`,
|
||||
`new_design`, `open`, `save`. Tool calls reach files only in the open design's folder (when it lies inside your home
|
||||
folder, never home itself or a hidden folder), SolidCraft's recovery folder and folders given with
|
||||
`--allow-path DIR`. Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Details, client
|
||||
configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
+67
-3
@@ -3,13 +3,53 @@
|
||||
Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch →
|
||||
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
|
||||
|
||||
## Integration map
|
||||
|
||||
Where pull requests are welcome right now. The CAD core comes first: sketching, solid
|
||||
modelling, assemblies and files have to be dependable before we build big new workspaces on
|
||||
top of them.
|
||||
|
||||
- **Open**: send pull requests. Bigger changes still start from an issue (see
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md#before-you-start)).
|
||||
- **Talk first**: open an issue or comment on the RFC and agree the approach before writing
|
||||
code. Pull requests without that agreement will usually be closed.
|
||||
- **Deferred**: not now. Ideas and requirements are welcome on the linked issues, but code
|
||||
pull requests won't be reviewed until the area opens.
|
||||
|
||||
| Area | Status | Why | Milestone |
|
||||
|---|---|---|---|
|
||||
| Solid modelling robustness (booleans, fillets, chamfers, shell, draft, patterns) | Open | Everything else is built on it. Each failing case is a clear, testable bug. | CAD core robustness |
|
||||
| Sketch and constraint solver | Open | Part of the CAD core. | CAD core robustness |
|
||||
| Parameters, configurations, timeline | Open | Part of the CAD core. | CAD core robustness |
|
||||
| Assemblies and joints | Open | In scope (M9) and in progress. | Assemblies |
|
||||
| Import, export and performance (STEP, IGES, 3MF, STL, OBJ, DXF; opening and rebuild speed) | Open | People need to get their work in and out. | Import, export & performance |
|
||||
| UI refresh (ribbon, toolbar, dialogs) | Open: coordinate on [#2016](https://github.com/tangency-labs/solidcraft/issues/2016) | The redesign is planned in one tracker so changes don't collide. | UI refresh |
|
||||
| Bug and crash fixes | Open | Always welcome, with a regression test. | Stability & bug fixes |
|
||||
| Translations | Open | See [docs/localization.md](docs/localization.md). | — |
|
||||
| Documentation | Open | | Contributor experience |
|
||||
| CI, tooling and contributor docs | Open | | Contributor experience |
|
||||
| Sheet metal and plastic | Open for fixes; talk first for new tools | The planned set (M10) is in; new tools need an agreed issue. | Stability & bug fixes |
|
||||
| Kernel internals | Talk first | The kernel is being replaced by [solidcore](https://github.com/bherbruck/solidcore). Don't rewrite the current kernel code for speed; report slow cases with the `kernel-perf` label. | solidcore kernel ([#1597](https://github.com/tangency-labs/solidcraft/issues/1597)) |
|
||||
| Surface modelling beyond what solids need | Talk first | Scope and order of work are in [#772](https://github.com/tangency-labs/solidcraft/issues/772). | — |
|
||||
| Render | Talk first | Work is planned in [#620](https://github.com/tangency-labs/solidcraft/issues/620); agree a piece there before starting. | — |
|
||||
| Drawings | Talk first | Needs a stable model underneath. Requirements and phases are in RFC [#860](https://github.com/tangency-labs/solidcraft/issues/860); foundations land in small packages ([docs/drawing-foundations.md](docs/drawing-foundations.md)). | — |
|
||||
| Manufacture / CAM | Deferred | The CAD core comes first: toolpaths are only as good as the model they are cut from, and CAM is a large area to review and maintain. Discussion: RFC [#1153](https://github.com/tangency-labs/solidcraft/issues/1153), plugin research [#1621](https://github.com/tangency-labs/solidcraft/issues/1621). | — |
|
||||
| Mesh workspace | Deferred | The CAD core comes first. Mesh import and Convert Mesh bug reports are still welcome. | — |
|
||||
| Form (T-splines) | Deferred | The CAD core comes first ([#446](https://github.com/tangency-labs/solidcraft/issues/446)). | — |
|
||||
| PCB / electronics | Deferred | The CAD core comes first. | — |
|
||||
| Simulation, animation | Deferred | The CAD core comes first. | — |
|
||||
|
||||
Milestones are the current sprint goals:
|
||||
[milestones on GitHub](https://github.com/tangency-labs/solidcraft/milestones). Maintainers
|
||||
review pull requests linked to issues in these milestones first.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; FreeCAD/Rhino import; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
|
||||
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
|
||||
| Reference tests | **82 / 82 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (61) or FreeCAD-computed (21) expected values, run by CI on every PR. |
|
||||
| Reference tests | [Current case count and results](docs/oracle.md): public cases in `tests/oracle/` with analytic or FreeCAD-computed expected values, checked by the reference-test job in [CI](.github/workflows/ci.yml). |
|
||||
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||
@@ -51,9 +91,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator, extrude taper), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
@@ -73,6 +113,11 @@ hierarchies are shared; bounded desktop preparation and occlusion use up to eigh
|
||||
Curves are approximated; drawing sheets,
|
||||
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
|
||||
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
|
||||
packages. The first geometry increment defines validated section records in a plane-local
|
||||
frame; the ledger separates that contract from section producers and the Drawing workspace.
|
||||
|
||||
Desktop view DXF export runs in the file-operation worker with five-stage progress and
|
||||
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
|
||||
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
|
||||
@@ -274,6 +319,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.
|
||||
|
||||
+80
-25
@@ -1,6 +1,6 @@
|
||||
Third-party software in SolidCraft
|
||||
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 549 crates below.
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 552 crates below.
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
|
||||
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
|
||||
|
||||
@@ -80,6 +80,7 @@ cipher 0.4.4 (MIT OR Apache-2.0)
|
||||
clipboard-win 5.4.1 (BSL-1.0)
|
||||
codespan-reporting 0.13.1 (Apache-2.0)
|
||||
color 0.3.3 (Apache-2.0 OR MIT)
|
||||
color_quant 1.1.0 (MIT)
|
||||
combine 4.6.8 (MIT)
|
||||
concurrent-queue 2.5.0 (Apache-2.0 OR MIT)
|
||||
const-oid 0.10.2 (Apache-2.0 OR MIT)
|
||||
@@ -156,6 +157,7 @@ gethostname 1.1.0 (Apache-2.0)
|
||||
getrandom 0.2.17 (MIT OR Apache-2.0)
|
||||
getrandom 0.3.4 (MIT OR Apache-2.0)
|
||||
getrandom 0.4.3 (MIT OR Apache-2.0)
|
||||
gif 0.14.2 (MIT OR Apache-2.0)
|
||||
glifo 0.2.0 (Apache-2.0 OR MIT)
|
||||
glow 0.17.0 (MIT OR Apache-2.0 OR Zlib)
|
||||
gltf 1.4.1 (MIT OR Apache-2.0)
|
||||
@@ -192,6 +194,7 @@ icu_provider 2.3.1 (Unicode-3.0)
|
||||
idna 1.1.0 (MIT OR Apache-2.0)
|
||||
idna_adapter 1.2.2 (Apache-2.0 OR MIT)
|
||||
image 0.25.10 (MIT OR Apache-2.0)
|
||||
image-webp 0.2.4 (MIT OR Apache-2.0)
|
||||
indexmap 2.14.2 (Apache-2.0 OR MIT)
|
||||
inflections 1.1.1 (MIT)
|
||||
inout 0.1.4 (MIT OR Apache-2.0)
|
||||
@@ -1098,23 +1101,23 @@ ahash 0.8.12, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-
|
||||
1.0.0, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23, dbus-secret-service
|
||||
4.1.0, displaydoc 0.2.7, either 1.19.0, equivalent 1.0.2, errno 0.3.14, euclid 0.22.14,
|
||||
event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, flate2 1.1.10, form_urlencoded
|
||||
1.2.2, futures-lite 2.6.1, gethostname 1.1.0, glow 0.17.0, gltf 1.4.1, gltf-derive 1.4.1, gltf-json
|
||||
1.4.1, hashbrown 0.16.1, hashbrown 0.17.1, hermit-abi 0.5.3, httparse 1.10.1, idna 1.1.0,
|
||||
idna_adapter 1.2.2, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106, khronos-egl
|
||||
6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34,
|
||||
num 0.4.3, num-bigint 0.4.8, num-complex 0.4.6, num-integer 0.1.47, num-iter 0.1.46, num-rational
|
||||
0.4.2, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, ordered-stream 0.2.0, parking
|
||||
2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain
|
||||
0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0,
|
||||
renderdoc-sys 1.1.0, robust 1.2.0, ron 0.12.2, roxmltree 0.21.1, rustc-hash 1.1.0, rustix 0.38.44,
|
||||
rustix 1.1.5, rustls 0.23.46, rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard 1.2.0,
|
||||
secret-service 4.0.0, security-framework 2.11.1, security-framework 3.7.0, security-framework-sys
|
||||
2.17.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.16.2, smol_str 0.2.2,
|
||||
stable_deref_trait 1.2.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1, unicode-segmentation
|
||||
1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2
|
||||
1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129,
|
||||
wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12,
|
||||
wit-bindgen 0.57.1
|
||||
1.2.2, futures-lite 2.6.1, gethostname 1.1.0, gif 0.14.2, glow 0.17.0, gltf 1.4.1, gltf-derive
|
||||
1.4.1, gltf-json 1.4.1, hashbrown 0.16.1, hashbrown 0.17.1, hermit-abi 0.5.3, httparse 1.10.1, idna
|
||||
1.1.0, idna_adapter 1.2.2, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106,
|
||||
khronos-egl 6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14,
|
||||
log 0.4.34, num 0.4.3, num-bigint 0.4.8, num-complex 0.4.6, num-integer 0.1.47, num-iter 0.1.46,
|
||||
num-rational 0.4.2, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, ordered-stream 0.2.0,
|
||||
parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5,
|
||||
plain 0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core
|
||||
1.13.0, renderdoc-sys 1.1.0, robust 1.2.0, ron 0.12.2, roxmltree 0.21.1, rustc-hash 1.1.0, rustix
|
||||
0.38.44, rustix 1.1.5, rustls 0.23.46, rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard
|
||||
1.2.0, secret-service 4.0.0, security-framework 2.11.1, security-framework 3.7.0,
|
||||
security-framework-sys 2.17.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.16.2,
|
||||
smol_str 0.2.2, stable_deref_trait 1.2.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1,
|
||||
unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, wasi
|
||||
0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures
|
||||
0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129,
|
||||
web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
@@ -1536,12 +1539,12 @@ at your option.
|
||||
|
||||
================================================================================
|
||||
android-activity 0.6.1, arboard 3.6.1, color 0.3.3, fdeflate 0.3.7, fearless_simd 0.4.1, half 2.7.1,
|
||||
image 0.25.10, linebender_resource_handle 0.1.1, miniz_oxide 0.8.9, miniz_oxide 0.9.1, naga 30.0.1,
|
||||
naga-types 30.0.1, pin-project 1.1.13, pin-project-internal 1.1.13, pin-project-lite 0.2.17,
|
||||
portable-atomic 1.15.0, portable-atomic-util 0.2.8, range-alloc 0.1.5, raw-window-handle 0.6.2,
|
||||
simdutf8 0.1.5, truck-geometry 0.5.0, truck-meshalgo 0.4.0, truck-shapeops 0.4.0, truck-stepio
|
||||
0.3.0, vello_common 0.1.0, vello_cpu 0.1.0, wgpu 30.0.1, wgpu-core 30.0.1, wgpu-core-deps-apple
|
||||
30.0.1, wgpu-core-deps-emscripten 30.0.1, wgpu-core-deps-wasm 30.0.1,
|
||||
image 0.25.10, image-webp 0.2.4, linebender_resource_handle 0.1.1, miniz_oxide 0.8.9, miniz_oxide
|
||||
0.9.1, naga 30.0.1, naga-types 30.0.1, pin-project 1.1.13, pin-project-internal 1.1.13,
|
||||
pin-project-lite 0.2.17, portable-atomic 1.15.0, portable-atomic-util 0.2.8, range-alloc 0.1.5,
|
||||
raw-window-handle 0.6.2, simdutf8 0.1.5, truck-geometry 0.5.0, truck-meshalgo 0.4.0, truck-shapeops
|
||||
0.4.0, truck-stepio 0.3.0, vello_common 0.1.0, vello_cpu 0.1.0, wgpu 30.0.1, wgpu-core 30.0.1,
|
||||
wgpu-core-deps-apple 30.0.1, wgpu-core-deps-emscripten 30.0.1, wgpu-core-deps-wasm 30.0.1,
|
||||
wgpu-core-deps-windows-linux-android 30.0.1, wgpu-hal 30.0.1, wgpu-naga-bridge 30.0.1, wgpu-types
|
||||
30.0.1
|
||||
================================================================================
|
||||
@@ -3595,6 +3598,32 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
color_quant 1.1.0
|
||||
================================================================================
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 PistonDevelopers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
combine 4.6.8
|
||||
================================================================================
|
||||
@@ -5318,7 +5347,7 @@ The above copyright notice and this permission notice shall be included in all c
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
fdeflate 0.3.7, image 0.25.10
|
||||
fdeflate 0.3.7, image 0.25.10, image-webp 0.2.4
|
||||
================================================================================
|
||||
|
||||
MIT License
|
||||
@@ -6152,6 +6181,32 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
gif 0.14.2
|
||||
================================================================================
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 nwin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
glifo 0.2.0
|
||||
================================================================================
|
||||
|
||||
@@ -423,6 +423,24 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
"sketch": profile_sketch(f), "profiles": profiles_of(f), "path_sketch": path.get("sketch"), "path": path.get("curves"),
|
||||
"operation": op(f), "name": name, "body_names": bodies}}));
|
||||
}
|
||||
// A boundary surface: a loop of one sketch's curves (`sketch`, `curves`), or a network
|
||||
// (`u`, `v`), as `surface.boundary` takes them.
|
||||
Some("boundary_surface") => {
|
||||
let mut p = json!({"name": name});
|
||||
for k in ["sketch", "curves", "continuity", "u", "v", "operation"] {
|
||||
if let Some(v) = f.get(k) {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(b) = bodies.as_array().and_then(|a| a.first()) {
|
||||
p["body_name"] = b.clone();
|
||||
}
|
||||
out.push(json!({"command": "surface.boundary", "params": p}));
|
||||
}
|
||||
Some("thicken") => out.push(json!({"command": "surface.thicken", "params": {
|
||||
"bodies": f.get("bodies"), "thickness": f.get("thickness"), "symmetric": f.get("symmetric").cloned().unwrap_or(json!(false)),
|
||||
"operation": op(f), "name": name}})),
|
||||
Some("remove") => out.push(json!({"command": "solid.remove", "params": {"bodies": f.get("bodies"), "name": name}})),
|
||||
Some("shell") => {
|
||||
let faces: Vec<Value> = f.get("faces_removed").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
|
||||
out.push(json!({"command": "solid.shell", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! The reference tests (tests/oracle, docs/oracle.md) as a `cargo test`, so tools that drive
|
||||
//! `cargo test` (cargo-mutants) can use them: every case with expected values and no
|
||||
//! `known_failure` mark must pass, as `cargo xtask oracle` requires.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[test]
|
||||
#[ignore = "run by cargo-mutants (.cargo/mutants.toml); CI replays the oracle through cargo xtask oracle"]
|
||||
fn oracle_suite() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let dir = root.join("tests/oracle");
|
||||
let mut cases: Vec<String> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.join("recipe.json").is_file())
|
||||
.map(|p| format!("tests/oracle/{}", p.file_name().unwrap().to_string_lossy()))
|
||||
.collect();
|
||||
cases.sort();
|
||||
assert!(cases.len() >= 50, "oracle cases in {}", dir.display());
|
||||
// One process per core, cases dealt round-robin so the slow ones spread out.
|
||||
let n = std::thread::available_parallelism().map_or(4, |n| n.get()).min(cases.len());
|
||||
let children: Vec<_> = (0..n)
|
||||
.map(|i| {
|
||||
let chunk: Vec<&String> = cases.iter().skip(i).step_by(n).collect();
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.current_dir(&root)
|
||||
.arg("oracle")
|
||||
.arg("--json")
|
||||
.args(chunk)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
for child in children {
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let part: Vec<Value> =
|
||||
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("oracle JSON: {e}\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
results.extend(part);
|
||||
}
|
||||
assert_eq!(results.len(), cases.len());
|
||||
let mut failed = Vec::new();
|
||||
for r in &results {
|
||||
let case = r["case"].as_str().unwrap_or("");
|
||||
let want: Value =
|
||||
std::fs::read_to_string(dir.join(case).join("expected.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
|
||||
if want.is_null() || want["known_failure"].is_string() || r["pass"].as_bool().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let checks: Vec<String> = r["checks"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|c| !c["ok"].as_bool().unwrap_or(false))
|
||||
.map(|c| format!("{} got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"]))
|
||||
.collect();
|
||||
failed.push(format!("{case}: {} {}", r["error"].as_str().unwrap_or(""), checks.join("; ")));
|
||||
}
|
||||
assert!(failed.is_empty(), "oracle cases failed:\n{}", failed.join("\n"));
|
||||
}
|
||||
@@ -13,8 +13,10 @@
|
||||
// unchanged, the kept build starts; when a new build is deployed, it is downloaded first
|
||||
// (waiting at most UPDATE_TIMEOUT_MS; the download goes on in the background after that) and
|
||||
// started. When the site has no `build.json` (the app was removed), launches go to the network,
|
||||
// the kept build answering when that fails. Each page then gets its files from the build it
|
||||
// started (remembered in PAGES), never from another build with a file of the same name.
|
||||
// the kept build answering when that fails. So they do when `build.json` is redirected or
|
||||
// refused (a sign-in in front of the app): the browser then shows the sign-in. Each page then
|
||||
// gets its files from the build it started (remembered in PAGES), never from another build
|
||||
// with a file of the same name.
|
||||
//
|
||||
// A cache is only deleted when nothing uses it: a download holds its lock (Web Locks, shared,
|
||||
// in any tab or worker; the browser releases it when the download ends or its worker stops), and
|
||||
@@ -158,9 +160,14 @@ function within(ms, promise) {
|
||||
/** No app is deployed here any more (the server says `build.json` doesn't exist). */
|
||||
const GONE = "gone";
|
||||
|
||||
/** The deployed build's version and files, GONE, or null (unknown: offline, failing, invalid). */
|
||||
/** The site wants a sign-in first (`build.json` is redirected, often to another origin, or refused). */
|
||||
const SIGN_IN = "sign-in";
|
||||
|
||||
/** The deployed build's version and files, GONE, SIGN_IN, or null (unknown: offline, failing, invalid). */
|
||||
async function deployed() {
|
||||
const response = await fetch(url("build.json"), { cache: "no-cache" });
|
||||
// `manual`: a redirect to another origin would only fail (CORS), looking like being offline.
|
||||
const response = await fetch(url("build.json"), { cache: "no-cache", redirect: "manual" });
|
||||
if (response.type === "opaqueredirect" || response.status === 401 || response.status === 403) return SIGN_IN;
|
||||
if (response.status === 404 || response.status === 410) return GONE;
|
||||
const build = response.ok ? await response.json() : null;
|
||||
const valid = build && typeof build.version === "string" && Array.isArray(build.files);
|
||||
@@ -182,6 +189,7 @@ async function launch(event) {
|
||||
if (!kept) return [await fetch(event.request), NETWORK]; // Nothing kept yet (the first visit).
|
||||
extend(event, sweep());
|
||||
const build = await within(NAVIGATE_TIMEOUT_MS, deployed());
|
||||
if (build === SIGN_IN) return [await fetch(event.request), NETWORK];
|
||||
if (build === GONE) {
|
||||
const page = await fetch(event.request).catch(() => null);
|
||||
return page && page.ok ? [page, NETWORK] : [kept, CACHE];
|
||||
|
||||
@@ -41,7 +41,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
|
||||
# Keychain on macOS, Credential Manager on Windows, the Secret Service (pure-Rust D-Bus) elsewhere.
|
||||
keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] }
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))'.dependencies]
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
@@ -65,6 +65,11 @@ windows-sys = { version = "0.61", features = [
|
||||
] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# The 3D mouse through the 3Dconnexion driver, which keeps the device to itself (src/space_mouse.rs).
|
||||
solidcraft-3dx-client = { workspace = true }
|
||||
# The 3D mouse (src/space_mouse.rs), in the shared-device open mode so that a running 3DxWare
|
||||
# driver does not shut the app out.
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["macos-shared-device"] }
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
|
||||
|
||||
@@ -292,13 +292,16 @@ mod tests {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// The slow request is held until the test has seen it time out, so the test does not
|
||||
// race the clock on a loaded machine.
|
||||
let (gate_tx, gate_rx) = channel::<()>();
|
||||
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let seen = ran.clone();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
seen.lock().unwrap().push(req.method.clone());
|
||||
if req.method == "slow" {
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
let _ = gate_rx.recv();
|
||||
}
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
@@ -325,8 +328,15 @@ mod tests {
|
||||
assert!(r["busy"] == true && r["id"] == 2, "{r}");
|
||||
let r = ask(json!({"id": 3, "method": "control.pending"}));
|
||||
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(1), &json!(false)), "{r}");
|
||||
std::thread::sleep(Duration::from_millis(600));
|
||||
let r = ask(json!({"id": 4, "method": "control.pending"}));
|
||||
gate_tx.send(()).unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
let r = loop {
|
||||
let r = ask(json!({"id": 4, "method": "control.pending"}));
|
||||
if r["result"]["done"] == true || std::time::Instant::now() > deadline {
|
||||
break r;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
assert_eq!(
|
||||
(&r["result"]["pending"], &r["result"]["done"], &r["result"]["reply"]["result"]),
|
||||
(&json!(1), &json!(true), &json!("slow")),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -116,6 +116,7 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
|
||||
let open_parent = parent.clone();
|
||||
let program_parent = parent.clone();
|
||||
let file_parent = parent.clone();
|
||||
Services {
|
||||
pick_open: Some(Box::new(move |first: &[&str]| {
|
||||
// The file type the command reads comes first, so the picker opens on it (#1801).
|
||||
@@ -166,6 +167,21 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
.pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
// One kind of file (the agent chat's attachments): only the types it names, as given and in
|
||||
// lower and upper case (Linux dialogs match case; macOS allows just the listed types).
|
||||
pick_file: Some(Box::new(move |label: &str, exts: &[&str]| {
|
||||
let mut all: Vec<String> = Vec::new();
|
||||
for e in exts {
|
||||
for v in [e.to_string(), e.to_ascii_lowercase(), e.to_ascii_uppercase()] {
|
||||
if !all.contains(&v) {
|
||||
all.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let all: Vec<&str> = all.iter().map(String::as_str).collect();
|
||||
let future = file_parent.clone().add_filter(label, &all).pick_file();
|
||||
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
|
||||
})),
|
||||
pick_program: Some(Box::new(move || {
|
||||
let dialog = program_parent.clone().set_title("Choose your slicer");
|
||||
let dialog = if cfg!(target_os = "windows") { dialog.add_filter("Programs", &["exe"]) } else { dialog };
|
||||
@@ -655,14 +671,14 @@ fn main() -> eframe::Result {
|
||||
if !notes.is_empty() {
|
||||
app.set_status(notes.join(" "), true);
|
||||
}
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
solidcraft_engine::crash::phase("first frame");
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
starting,
|
||||
frames: 0,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
|
||||
space_mouse,
|
||||
}))
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 3D mouse navigation on Windows (through hidapi) and Linux (through spacenavd when it runs, else
|
||||
//! 3D mouse navigation on Windows and macOS (through hidapi) and Linux (through spacenavd when it runs, else
|
||||
//! hidraw). Read directly, any HID multi-axis controller works: 3Dconnexion's and homemade ones.
|
||||
//! The device reader never touches the document or UI; it publishes the latest axes, and the egui
|
||||
//! thread applies them once per frame.
|
||||
@@ -8,11 +8,12 @@ mod hidraw;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod spacenavd;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
@@ -21,6 +22,14 @@ use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame};
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
/// Whether the 3Dconnexion driver (macOS) has the device: it then reports the cap, and reading
|
||||
/// the HID device would fail or report it twice.
|
||||
static DRIVER_CONNECTED: AtomicBool = AtomicBool::new(false);
|
||||
/// Whether this app registered with the 3Dconnexion driver (macOS). The driver then has the device
|
||||
/// whether or not it has said so yet, so a failure to open it by HID is no news.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
static DRIVER_REGISTERED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(2);
|
||||
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
|
||||
/// Room for one input report: the 4096 bytes `solidcraft_space_mouse` places fields in, plus the
|
||||
@@ -188,7 +197,7 @@ impl Reading {
|
||||
|
||||
/// The Generic Desktop "Multi-Axis Controller" usage names a 3D mouse's motion interface, whoever
|
||||
/// made it, and excludes ordinary mice and the keyboard interfaces of 3D mice.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn is_3d_mouse(info: &DeviceInfo) -> bool {
|
||||
info.usage_page() == 0x01 && info.usage() == 0x08
|
||||
}
|
||||
@@ -210,6 +219,9 @@ fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Inst
|
||||
|
||||
/// Publishes one input report from the device to the egui thread.
|
||||
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
|
||||
if DRIVER_CONNECTED.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(report, layout, &mut reading, Instant::now())
|
||||
{
|
||||
@@ -234,7 +246,7 @@ fn reset(ctx: &Context, shared: &Mutex<Reading>) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
let Ok(mut api) = HidApi::new() else {
|
||||
log::warn!("3D mouse: HID initialization failed");
|
||||
@@ -244,7 +256,16 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
log::warn!("3D mouse: device scan failed: {error}");
|
||||
} else if let Some((device, layout)) = api.device_list().filter(|info| is_3d_mouse(info)).find_map(|info| {
|
||||
let device = info.open_device(&api).ok()?;
|
||||
let device = info
|
||||
.open_device(&api)
|
||||
.inspect_err(|error| {
|
||||
// On macOS: missing Input Monitoring permission, or the 3DxWare helper holding the device.
|
||||
// The driver having the device is the reason it cannot be opened, not a problem.
|
||||
if !DRIVER_CONNECTED.load(Ordering::Relaxed) && !DRIVER_REGISTERED.load(Ordering::Relaxed) {
|
||||
log::warn!("3D mouse: cannot open the device: {error}");
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
let layout = device_layout(&device, info.vendor_id())?;
|
||||
Some((device, layout))
|
||||
}) {
|
||||
@@ -270,7 +291,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
/// read, or it's empty), a 3Dconnexion device (Logitech's id for the old SpaceNavigator,
|
||||
/// 3Dconnexion's own for the rest) gets 3Dconnexion's layout, and any other device is left alone
|
||||
/// rather than guessed at.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
fn device_layout(device: &hidapi::HidDevice, vendor_id: u16) -> Option<Layout> {
|
||||
let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
|
||||
let read = device.get_report_descriptor(&mut descriptor);
|
||||
@@ -335,6 +356,34 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS: the 3Dconnexion driver gives the device to its own clients only, so SolidCraft registers
|
||||
/// as one. Without the driver (or with its helper not running) the HID reader above takes over.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn start_driver_client(ctx: &Context, shared: &Arc<Mutex<Reading>>) {
|
||||
use solidcraft_3dx_client::Event;
|
||||
let (ctx, shared) = (ctx.clone(), Arc::clone(shared));
|
||||
let started = solidcraft_3dx_client::start("SolidCraft", move |event| match event {
|
||||
Event::DeviceAdded => DRIVER_CONNECTED.store(true, Ordering::Relaxed),
|
||||
Event::DeviceRemoved => {
|
||||
DRIVER_CONNECTED.store(false, Ordering::Relaxed);
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
Event::Axes { translation, rotation } => {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
let now = Instant::now();
|
||||
reading.axes = Axes { translation, rotation };
|
||||
reading.at = [now; 6];
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Event::Buttons(_) => {}
|
||||
});
|
||||
match started {
|
||||
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
|
||||
Err(error) => log::info!("3D mouse: no driver client: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Reading>>,
|
||||
last_frame: Instant,
|
||||
@@ -354,6 +403,8 @@ impl SpaceMouse {
|
||||
if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
log::warn!("3D mouse: reader could not start: {error}");
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
start_driver_client(ctx, &shared);
|
||||
Self {
|
||||
shared,
|
||||
last_frame: now,
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"Density": "Hustota",
|
||||
"Designs you open or save appear here.": "Zde se zobrazí návrhy, které otevřete nebo uložíte.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Určeno volbou K objektu. Pro úpravu úhlového rozsahu zvolte Úhel.",
|
||||
"Direction": "Směr",
|
||||
"Discard": "Zahodit",
|
||||
"Do not export hidden edges": "Neexportovat skryté hrany",
|
||||
"Double-click to rename": "Dvojklikem přejmenujete",
|
||||
@@ -112,7 +113,6 @@
|
||||
"Follow the camera (Look At sketches)": "Sledovat kameru (náčrty natočené k pohledu)",
|
||||
"Font": "Písmo",
|
||||
"Full Length": "Celá délka",
|
||||
"Gap": "Mezera",
|
||||
"Grid": "Mřížka",
|
||||
"Height": "Výška",
|
||||
"Hidden edges": "Skryté hrany",
|
||||
@@ -121,6 +121,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 +155,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",
|
||||
@@ -1028,6 +1030,7 @@
|
||||
"ui.construct": "Konstrukce",
|
||||
"ui.construction": "Konstrukční",
|
||||
"ui.context_menu": "Místní nabídka",
|
||||
"ui.continuity_gap": "Mezera",
|
||||
"ui.copy": "Kopírovat",
|
||||
"ui.corner": "Roh",
|
||||
"ui.corners": "Rohy",
|
||||
@@ -1344,6 +1347,7 @@
|
||||
"ui.send": "Odeslat",
|
||||
"ui.send_to_slicer": "Odeslat do sliceru",
|
||||
"ui.sent_to_slicer": "Odesláno do {slicer}",
|
||||
"ui.sheet_gap": "Mezera",
|
||||
"ui.shell": "Skořepina",
|
||||
"ui.show": "Zobrazit",
|
||||
"ui.showAll": "Zobrazit vše",
|
||||
|
||||
+12
-1
@@ -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",
|
||||
@@ -42,11 +43,11 @@
|
||||
"Faces": "Flächen",
|
||||
"Find in Browser": "Im Browser suchen",
|
||||
"Flat": "Flach",
|
||||
"Gap": "Spalt",
|
||||
"Hidden edges": "Verdeckte Kanten",
|
||||
"Holes": "Löcher",
|
||||
"Holes (edges on them)": "Löcher (ihre Kanten)",
|
||||
"In place (surfaces)": "An Ort und Stelle (Flächen)",
|
||||
"In plane": "In der Ebene",
|
||||
"Inside": "Innen",
|
||||
"Length": "Länge",
|
||||
"Max": "Max",
|
||||
@@ -55,6 +56,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",
|
||||
@@ -445,7 +447,12 @@
|
||||
"coupling.rack": "Zahnstange",
|
||||
"coupling.radius_1": "Radius 1",
|
||||
"coupling.radius_2": "Radius 2",
|
||||
"dialog.bodies_count": "{count} Körper",
|
||||
"dialog.clearance_invalid": "der Abstand muss eine Länge von 0 oder mehr sein",
|
||||
"dialog.compute": "Berechnen",
|
||||
"dialog.compute_again": "Erneut berechnen",
|
||||
"dialog.computing": "Wird berechnet… {seconds} s",
|
||||
"dialog.could_not_be_checked": "nicht prüfbar",
|
||||
"dialog.custom_axis": "↖ Eigene Achse",
|
||||
"dialog.edit_title": "{title} bearbeiten",
|
||||
"dialog.select": "↖ Auswählen",
|
||||
@@ -766,6 +773,7 @@
|
||||
"ui.base_point": "Basispunkt",
|
||||
"ui.blocked": "Verdeckt",
|
||||
"ui.bodies": "Körper",
|
||||
"ui.bodies_all_if_none": "Körper (alle, wenn keine)",
|
||||
"ui.box": "Block",
|
||||
"ui.break": "Unterbrechen",
|
||||
"ui.calibrate": "Kalibrieren",
|
||||
@@ -816,6 +824,7 @@
|
||||
"ui.continuity_g0": "G0 Knick",
|
||||
"ui.continuity_g1": "G1 Tangente",
|
||||
"ui.continuity_g2": "G2 Krümmung",
|
||||
"ui.continuity_gap": "Lücke",
|
||||
"ui.copy": "Kopieren",
|
||||
"ui.corner": "Ecke",
|
||||
"ui.corners": "Ecken",
|
||||
@@ -925,6 +934,7 @@
|
||||
"ui.heading_home": "START",
|
||||
"ui.heading_insert": "EINFÜGEN",
|
||||
"ui.heading_inspect": "PRÜFEN",
|
||||
"ui.heading_interference": "EINGRIFF",
|
||||
"ui.heading_loft": "LOFT",
|
||||
"ui.heading_measure": "Messen",
|
||||
"ui.heading_mesh": "MESH",
|
||||
@@ -1118,6 +1128,7 @@
|
||||
"ui.send": "Senden",
|
||||
"ui.send_to_slicer": "An Slicer senden",
|
||||
"ui.sent_to_slicer": "An {slicer} gesendet",
|
||||
"ui.sheet_gap": "Spalt",
|
||||
"ui.shell": "Hülle",
|
||||
"ui.show": "Einblenden",
|
||||
"ui.showAll": "Alle einblenden",
|
||||
|
||||
+59
-1
@@ -31,6 +31,7 @@
|
||||
"Arc length": "Arc length",
|
||||
"Area": "Area",
|
||||
"Attachment": "Attachment",
|
||||
"BOUNDARY SURFACE": "BOUNDARY SURFACE",
|
||||
"BROWSER": "BROWSER",
|
||||
"Bend Line Position": "Bend Line Position",
|
||||
"Bend Position": "Bend Position",
|
||||
@@ -73,6 +74,7 @@
|
||||
"Counterbore depth": "Counterbore depth",
|
||||
"Countersink angle": "Countersink angle",
|
||||
"Create Issue — review draft": "Create Issue — review draft",
|
||||
"Cross curves": "Cross curves",
|
||||
"Cube": "Cube",
|
||||
"Current orthographic view (1:1 mm)": "Current orthographic view (1:1 mm)",
|
||||
"Current perspective view": "Current perspective view",
|
||||
@@ -86,6 +88,7 @@
|
||||
"Density": "Density",
|
||||
"Designs you open or save appear here.": "Designs you open or save appear here.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Determined by To Object. Choose Angle to edit the angular extent.",
|
||||
"Direction": "Direction",
|
||||
"Discard": "Discard",
|
||||
"Do not export hidden edges": "Do not export hidden edges",
|
||||
"Don't ask again": "Don't ask again",
|
||||
@@ -119,7 +122,6 @@
|
||||
"Follow the camera (Look At sketches)": "Follow the camera (Look At sketches)",
|
||||
"Font": "Font",
|
||||
"Full Length": "Full Length",
|
||||
"Gap": "Gap",
|
||||
"Grid": "Grid",
|
||||
"Height": "Height",
|
||||
"Hidden edges": "Hidden edges",
|
||||
@@ -128,6 +130,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 +166,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 +559,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",
|
||||
@@ -649,7 +655,12 @@
|
||||
"coupling.rack": "Rack",
|
||||
"coupling.radius_1": "Radius 1",
|
||||
"coupling.radius_2": "Radius 2",
|
||||
"dialog.bodies_count": "{count} bodies",
|
||||
"dialog.clearance_invalid": "the clearance must be a length of 0 or more",
|
||||
"dialog.compute": "Compute",
|
||||
"dialog.compute_again": "Compute again",
|
||||
"dialog.computing": "Computing… {seconds} s",
|
||||
"dialog.could_not_be_checked": "could not be checked",
|
||||
"dialog.custom_axis": "↖ Custom Axis",
|
||||
"dialog.edit_title": "EDIT {title}",
|
||||
"dialog.select": "↖ Select",
|
||||
@@ -719,9 +730,11 @@
|
||||
"new configuration": "new configuration",
|
||||
"new name": "new name",
|
||||
"new parameter": "new parameter",
|
||||
"no hole": "no hole",
|
||||
"no interference": "no interference",
|
||||
"no joint that moves yet": "no joint that moves yet",
|
||||
"no picture": "no picture",
|
||||
"none": "none",
|
||||
"pattern.alignment_2": "Alignment 2",
|
||||
"pattern.alignment_hint": "Counts include the original. Center balances copies; Flip chooses the extra side for even totals.",
|
||||
"pattern.direction_1": "Direction 1",
|
||||
@@ -794,6 +807,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",
|
||||
@@ -806,6 +821,7 @@
|
||||
"sketch_text.path_clear": "Take the text off its curve",
|
||||
"sketch_text.path_hint": "A curve of the sketch the text follows: click here, then the curve",
|
||||
"sketch_text.use_font_file": "Use the font file {path}",
|
||||
"solid": "solid",
|
||||
"the parts library is not available": "the parts library is not available",
|
||||
"thread.automatic_size": "Automatic size",
|
||||
"thread.designation": "Designation",
|
||||
@@ -817,6 +833,7 @@
|
||||
"timeline.rename_group": "Rename Feature Folder",
|
||||
"timeline.reorder_many": "Reorder Features",
|
||||
"timeline.ungroup": "Remove Feature Folders",
|
||||
"to the face": "to the face",
|
||||
"to the next face": "to the next face",
|
||||
"toolbar.image": "IMAGE",
|
||||
"toolbar.render": "RENDER",
|
||||
@@ -832,12 +849,15 @@
|
||||
"ui.activate": "Activate",
|
||||
"ui.active_component": "Active component",
|
||||
"ui.agent_chat": "Agent chat",
|
||||
"ui.agent_chat_add": "Attach files",
|
||||
"ui.agent_chat_add_file": "Choose a file…",
|
||||
"ui.agent_chat_allow": "Allow",
|
||||
"ui.agent_chat_allowed": "Allowed",
|
||||
"ui.agent_chat_answered": "Answer: {option}",
|
||||
"ui.agent_chat_approval": "Allow the agent to do this?",
|
||||
"ui.agent_chat_attach_view": "Attach view",
|
||||
"ui.agent_chat_attach_view_tip": "Send a picture of the current view with the message",
|
||||
"ui.agent_chat_attachable": "Pictures, text and CAD files",
|
||||
"ui.agent_chat_auth_agent": "Sign in with {name}",
|
||||
"ui.agent_chat_auth_env": "Set {vars} in your environment and restart SolidCraft.",
|
||||
"ui.agent_chat_auth_needed": "The agent asks you to sign in. You sign in with the agent itself; SolidCraft never sees your password or keys.",
|
||||
@@ -857,11 +877,33 @@
|
||||
"ui.agent_chat_deny": "Deny",
|
||||
"ui.agent_chat_desktop_only": "The agent chat needs the desktop app.",
|
||||
"ui.agent_chat_detecting": "Looking for agents…",
|
||||
"ui.agent_chat_dock": "Dock in the side panel",
|
||||
"ui.agent_chat_dock_left": "Dock on the left",
|
||||
"ui.agent_chat_dock_right": "Dock on the right",
|
||||
"ui.agent_chat_drop_hint": "Drop to attach to your next message",
|
||||
"ui.agent_chat_edit": "Edit",
|
||||
"ui.agent_chat_exited": "The agent stopped.",
|
||||
"ui.agent_chat_failed_start": "The agent could not start: {error}",
|
||||
"ui.agent_chat_file_cad_not_read": "{name}: a CAD file is imported into the design, not read",
|
||||
"ui.agent_chat_file_empty_picture": "{name}: an empty picture",
|
||||
"ui.agent_chat_file_error": "{name}: {error}",
|
||||
"ui.agent_chat_file_heic": "{name}: HEIC pictures can't be read here; export it as JPEG or PNG first",
|
||||
"ui.agent_chat_file_importing": "Importing into the design…",
|
||||
"ui.agent_chat_file_kinds": "{name}: pictures (PNG, JPEG, GIF, WebP, BMP, TIFF), text files (SVG too) and CAD files (STEP, IGES, FreeCAD, Rhino, STL, 3MF, OBJ, glTF) can be attached",
|
||||
"ui.agent_chat_file_loading": "Reading the file…",
|
||||
"ui.agent_chat_file_not_a_file": "{name}: not a file",
|
||||
"ui.agent_chat_file_not_a_picture": "{name}: not a picture",
|
||||
"ui.agent_chat_file_not_utf8": "{name}: not UTF-8 text",
|
||||
"ui.agent_chat_file_picture_too_big": "{name}: a picture can be at most {max} MB",
|
||||
"ui.agent_chat_file_text_too_big": "{name}: a text file can be at most {max} KB",
|
||||
"ui.agent_chat_file_too_large": "{name}: too large",
|
||||
"ui.agent_chat_file_too_large_to_send": "{name}: too large to send, even scaled down",
|
||||
"ui.agent_chat_file_unreadable_picture": "{name}: not a picture this can read",
|
||||
"ui.agent_chat_files": "Files: {files}",
|
||||
"ui.agent_chat_files_left_out": "At most {max} files go with one message; left out: {files}",
|
||||
"ui.agent_chat_files_loading": "Waiting for the files to be read",
|
||||
"ui.agent_chat_files_max": "At most {max} files on one message",
|
||||
"ui.agent_chat_import_failed": "{name}: the import failed: {error}",
|
||||
"ui.agent_chat_intro": "Chat with a coding agent you already use, or a model endpoint you choose. It works on the open design through SolidCraft's tools: its changes show as it makes them and undo as one step per message. What it may do without asking is set in Preferences > AI.",
|
||||
"ui.agent_chat_key_in_env": "key set",
|
||||
"ui.agent_chat_left": "Left",
|
||||
@@ -874,14 +916,18 @@
|
||||
"ui.agent_chat_no_cli": "solidcraft-cli was not found next to the app, so the agent can't reach the design.",
|
||||
"ui.agent_chat_no_control": "The agent chat's endpoint did not start, so the agent can't reach the design.",
|
||||
"ui.agent_chat_no_images": "This agent doesn't take pictures",
|
||||
"ui.agent_chat_no_picture": "There is no picture on the clipboard",
|
||||
"ui.agent_chat_none_found": "No coding agent found. Install one of these, then press Refresh:",
|
||||
"ui.agent_chat_none_found_short": "No agent found",
|
||||
"ui.agent_chat_not_installed": "Not installed",
|
||||
"ui.agent_chat_not_signed_in": "not signed in",
|
||||
"ui.agent_chat_own_window": "Own window",
|
||||
"ui.agent_chat_paste_picture": "Paste a picture",
|
||||
"ui.agent_chat_permission": "The agent asks to run: {title}",
|
||||
"ui.agent_chat_pick": "Agent",
|
||||
"ui.agent_chat_placeholder": "Ask the agent to change the design…",
|
||||
"ui.agent_chat_placeholder_busy": "Queue a message for when the agent is done (Ctrl+Enter: send now)",
|
||||
"ui.agent_chat_pop_out": "Open in its own window, which you can move to another screen",
|
||||
"ui.agent_chat_privacy": "Nothing goes anywhere except to the agent you run, under your own account.",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft sends nothing anywhere: the agent you run talks to its own service under your own account. SolidCraft never sees or stores a key or password.",
|
||||
"ui.agent_chat_queue": "Queue",
|
||||
@@ -891,6 +937,8 @@
|
||||
"ui.agent_chat_refresh": "Refresh",
|
||||
"ui.agent_chat_refused": "Not allowed for the agent: {reason}",
|
||||
"ui.agent_chat_remove": "Remove from the queue",
|
||||
"ui.agent_chat_remove_file": "Take it off this message",
|
||||
"ui.agent_chat_remove_imported": "Take it off this message; the part stays in the design (Undo removes it)",
|
||||
"ui.agent_chat_right": "Right",
|
||||
"ui.agent_chat_save": "Save",
|
||||
"ui.agent_chat_send": "Send",
|
||||
@@ -1005,6 +1053,7 @@
|
||||
"ui.base_point": "Base point",
|
||||
"ui.blocked": "Blocked",
|
||||
"ui.bodies": "Bodies",
|
||||
"ui.bodies_all_if_none": "Bodies (all if none)",
|
||||
"ui.box": "Box",
|
||||
"ui.break": "Break",
|
||||
"ui.calibrate": "Calibrate",
|
||||
@@ -1059,6 +1108,7 @@
|
||||
"ui.continuity_g0": "G0 crease",
|
||||
"ui.continuity_g1": "G1 tangent",
|
||||
"ui.continuity_g2": "G2 curvature",
|
||||
"ui.continuity_gap": "Gap",
|
||||
"ui.copy": "Copy",
|
||||
"ui.corner": "Corner",
|
||||
"ui.corners": "Corners",
|
||||
@@ -1174,6 +1224,7 @@
|
||||
"ui.heading_home": "HOME",
|
||||
"ui.heading_insert": "INSERT",
|
||||
"ui.heading_inspect": "INSPECT",
|
||||
"ui.heading_interference": "INTERFERENCE",
|
||||
"ui.heading_loft": "LOFT",
|
||||
"ui.heading_make": "MAKE",
|
||||
"ui.heading_measure": "MEASURE",
|
||||
@@ -1298,6 +1349,7 @@
|
||||
"ui.one_side": "One Side",
|
||||
"ui.open": "Open",
|
||||
"ui.open_ellipsis": "Open...",
|
||||
"ui.open_ends_tip": "Touches nothing",
|
||||
"ui.open_preferences": "Open Preferences",
|
||||
"ui.open_recent": "Open Recent",
|
||||
"ui.operation": "Operation",
|
||||
@@ -1359,6 +1411,8 @@
|
||||
"ui.reset_the_settings_on_this_page": "Reset the settings on this page",
|
||||
"ui.restore": "Restore",
|
||||
"ui.restore_defaults": "Restore Defaults",
|
||||
"ui.reverse_horizontal_pan_direction": "Reverse horizontal pan direction",
|
||||
"ui.reverse_horizontal_pan_tip": "Horizontal two-finger scrolling and the thumb wheel pan the other way",
|
||||
"ui.reverse_zoom_direction": "Reverse zoom direction",
|
||||
"ui.revolve": "Revolve",
|
||||
"ui.right": "Right",
|
||||
@@ -1380,6 +1434,7 @@
|
||||
"ui.send": "Send",
|
||||
"ui.send_to_slicer": "Send to slicer",
|
||||
"ui.sent_to_slicer": "Sent to {slicer}",
|
||||
"ui.sheet_gap": "Gap",
|
||||
"ui.shell": "Shell",
|
||||
"ui.show": "Show",
|
||||
"ui.showAll": "Show All",
|
||||
@@ -1440,6 +1495,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 +1519,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",
|
||||
|
||||
+79
-15
@@ -7,6 +7,7 @@
|
||||
"A new configuration, a copy of the active one": "Una configuración nueva, copia de la activa",
|
||||
"AI": "AI",
|
||||
"About SolidCraft": "Acerca de SolidCraft",
|
||||
"Acceleration": "Aceleración",
|
||||
"Active": "Activo",
|
||||
"Active rule": "Regla activa",
|
||||
"Add": "Añadir",
|
||||
@@ -14,8 +15,10 @@
|
||||
"Add Mid Radius": "Añadir radio intermedio",
|
||||
"Add Row": "Añadir fila",
|
||||
"Add condition": "Añadir condición",
|
||||
"Against": "Contra",
|
||||
"Alignment": "Alineación",
|
||||
"All Sketch Points": "Todos los puntos del boceto",
|
||||
"All components": "Todos los componentes",
|
||||
"All open designs": "Todos los diseños abiertos",
|
||||
"Allowed expressions, separated by semicolons": "Expresiones permitidas, separadas por punto y coma",
|
||||
"Also deletes": "También elimina",
|
||||
@@ -57,6 +60,7 @@
|
||||
"Configuration rules": "Reglas de configuración",
|
||||
"Construction": "Construcción",
|
||||
"Contact": "Contacto",
|
||||
"Contact sets": "Conjuntos de contacto",
|
||||
"Continuity": "Continuidad",
|
||||
"Copy Error Message": "Copiar mensaje de error",
|
||||
"Copy Warning Message": "Copiar mensaje de advertencia",
|
||||
@@ -82,16 +86,21 @@
|
||||
"Density": "Densidad",
|
||||
"Designs you open or save appear here.": "Aquí aparecen los diseños que abras o guardes.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por «Hasta objeto». Elige «Ángulo» para modificar la extensión angular.",
|
||||
"Direction": "Dirección",
|
||||
"Discard": "Descartar",
|
||||
"Do not export hidden edges": "No exportar aristas ocultas",
|
||||
"Don't ask again": "No volver a preguntar",
|
||||
"Double-click to rename": "Doble clic para cambiar el nombre",
|
||||
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Doble clic con el botón central en la vista: Inicio (vista inicial que encuadra lo visible), Ajustar (encuadrar sin cambiar la orientación) o nada",
|
||||
"Drive Joint": "Accionar unión",
|
||||
"Duration": "Duración",
|
||||
"Edges": "Aristas",
|
||||
"Edit Profile Sketch": "Editar boceto del perfil",
|
||||
"Enable rule": "Activar regla",
|
||||
"Enabled": "Activado",
|
||||
"Explode": "Explosionar",
|
||||
"Explode Text": "Descomponer texto",
|
||||
"Export Animation…": "Exportar animación…",
|
||||
"Export Current View as DXF": "Exportar vista actual como DXF",
|
||||
"Export Current View as DXF…": "Exportar vista actual como DXF…",
|
||||
"Export DXF…": "Exportar DXF…",
|
||||
@@ -111,7 +120,6 @@
|
||||
"Follow the camera (Look At sketches)": "Seguir la cámara (orientar hacia los bocetos)",
|
||||
"Font": "Fuente",
|
||||
"Full Length": "Longitud total",
|
||||
"Gap": "Separación",
|
||||
"Grid": "Cuadrícula",
|
||||
"Height": "Altura",
|
||||
"Hidden edges": "Aristas ocultas",
|
||||
@@ -120,6 +128,7 @@
|
||||
"Home view": "Vista de inicio",
|
||||
"In This Design": "En este diseño",
|
||||
"In place (surfaces)": "En su posición (superficies)",
|
||||
"In plane": "En el plano",
|
||||
"Inside": "Interior",
|
||||
"Interferes": "Interfiere",
|
||||
"Issue title": "Título de la incidencia",
|
||||
@@ -149,10 +158,13 @@
|
||||
"Move earlier": "Mover antes",
|
||||
"Move later": "Mover después",
|
||||
"Name": "Nombre",
|
||||
"Nearest": "Más cercano",
|
||||
"New study": "Nuevo estudio",
|
||||
"No crash reports.": "No hay informes de fallos.",
|
||||
"No matching appearances": "No hay apariencias coincidentes",
|
||||
"No visible bodies to export.": "No hay cuerpos visibles para exportar.",
|
||||
"None": "Ninguno",
|
||||
"Normal to sketch": "Normal al boceto",
|
||||
"Not available yet": "Todavía no disponible",
|
||||
"Notch": "Muesca",
|
||||
"Opacity": "Opacidad",
|
||||
@@ -167,16 +179,20 @@
|
||||
"Per Edge": "Por arista",
|
||||
"Pick the tool bodies": "Selecciona los cuerpos herramienta",
|
||||
"Picked": "Seleccionado",
|
||||
"Picked point": "Punto seleccionado",
|
||||
"Place at the origin instead": "Colocar en el origen",
|
||||
"Playback": "Reproducción",
|
||||
"Plot": "Graficar",
|
||||
"Position": "Posición",
|
||||
"Position (0 to 1)": "Posición (de 0 a 1)",
|
||||
"Position (G0)": "Posición (G0)",
|
||||
"Position along the edge, 0 at the start end, 1 at the other": "Posición a lo largo de la arista: 0 en el extremo inicial y 1 en el otro",
|
||||
"Preferences › Design turns the offer back on": "Preferencias › Diseño vuelve a activar la oferta",
|
||||
"Preview Row": "Previsualizar fila",
|
||||
"Preview leaves the current design unchanged.": "La vista previa no modifica el diseño actual.",
|
||||
"Profiles/Curves": "Perfiles/curvas",
|
||||
"Projection": "Proyección",
|
||||
"Quantity": "Cantidad",
|
||||
"RECENT": "RECIENTES",
|
||||
"RULED SURFACE": "SUPERFICIE REGLADA",
|
||||
"Radius Fit": "Ajuste de radio",
|
||||
@@ -207,10 +223,11 @@
|
||||
"Revert Position": "Revertir posición",
|
||||
"Review the report and screenshot before opening GitHub.": "Revisa el informe y la captura antes de abrir GitHub.",
|
||||
"Rim Edge": "Arista del borde",
|
||||
"Roll History Marker Here": "Desplazar marcador del historial hasta aquí",
|
||||
"Roll History Marker Here": "Desplazar marcador de la línea de tiempo hasta aquí",
|
||||
"Round": "Redondo",
|
||||
"Rule": "Regla",
|
||||
"Save Rule": "Guardar regla",
|
||||
"Save the study as an animated GIF, seen as the viewport shows it": "Guardar el estudio como GIF animado, tal como se ve en el visor",
|
||||
"Saved preview. Geometry is being checked before editing.": "Vista previa guardada. Se está comprobando la geometría antes de editarla.",
|
||||
"Scale factor": "Factor de escala",
|
||||
"Search": "Buscar",
|
||||
@@ -241,6 +258,7 @@
|
||||
"Stand this part up?": "¿Poner esta pieza en vertical?",
|
||||
"Step": "Paso",
|
||||
"Steps": "Pasos",
|
||||
"Study": "Estudio",
|
||||
"Style": "Estilo",
|
||||
"Suppressed": "Suprimido",
|
||||
"Swap Ends": "Intercambiar extremos",
|
||||
@@ -260,6 +278,7 @@
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara no permite una vinculación persistente. Elige otra cara o utiliza expresamente un plano fijo.",
|
||||
"To Offset": "Hasta desfase",
|
||||
"Tolerance": "Tolerancia",
|
||||
"Too close": "Demasiado cerca",
|
||||
"Turn off section analysis before exporting the view.": "Desactiva el análisis de sección antes de exportar la vista.",
|
||||
"Type": "Tipo",
|
||||
"UNTRIM": "ANULAR RECORTE",
|
||||
@@ -270,6 +289,7 @@
|
||||
"Use selected face as fixed plane": "Usar la cara seleccionada como plano fijo",
|
||||
"VIEW": "VISTA",
|
||||
"Value": "Valor",
|
||||
"Velocity": "Velocidad",
|
||||
"Vertices": "Vértices",
|
||||
"View navigator": "Navegador de vistas",
|
||||
"Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Solo cuerpos visibles. Las curvas utilizan segmentos rectos (tolerancia de 0,01 mm).",
|
||||
@@ -293,6 +313,13 @@
|
||||
"browser.precision": "Precisión: {precision}",
|
||||
"browser.sketch_name": "Boceto{number}",
|
||||
"browser.units": "Unidades: {units}",
|
||||
"cam.flat": "Cara plana",
|
||||
"cam.follower": "Seguidor",
|
||||
"cam.knife": "Arista de cuchilla",
|
||||
"cam.needs_joints": "necesita una unión que gire y otra que deslice o bascule",
|
||||
"cam.roller": "Rodillo",
|
||||
"cam.roller_pick": "Rodillo (opcional)",
|
||||
"cam.tip_pick": "Punta (opcional)",
|
||||
"cmd.appearance_assign": "Apariencia",
|
||||
"cmd.appearance_library": "Biblioteca de apariencias",
|
||||
"cmd.appearance_list": "Listar apariencias",
|
||||
@@ -385,6 +412,7 @@
|
||||
"cmd.file_slicers": "Laminadores instalados",
|
||||
"cmd.inspect_accessibility": "Análisis de accesibilidad",
|
||||
"cmd.inspect_center_of_mass": "Centro de masas",
|
||||
"cmd.inspect_clearance": "Holgura",
|
||||
"cmd.inspect_compare": "Comparar cuerpos",
|
||||
"cmd.inspect_continuity": "Análisis de continuidad",
|
||||
"cmd.inspect_curvature_comb": "Análisis de peine de curvatura",
|
||||
@@ -394,8 +422,10 @@
|
||||
"cmd.inspect_interference": "Interferencia",
|
||||
"cmd.inspect_isocurve": "Análisis de isocurvas",
|
||||
"cmd.inspect_minimum_radius": "Análisis de radio mínimo",
|
||||
"cmd.inspect_silhouette": "Silueta",
|
||||
"cmd.inspect_zebra": "Análisis de franjas cebra",
|
||||
"cmd.joint_as_built": "Unión según posición actual",
|
||||
"cmd.joint_cam": "Leva",
|
||||
"cmd.joint_create": "Unión",
|
||||
"cmd.joint_delete": "Eliminar unión",
|
||||
"cmd.joint_drive": "Accionar uniones",
|
||||
@@ -426,8 +456,11 @@
|
||||
"cmd.model_threads": "Listar roscas",
|
||||
"cmd.motion_delete": "Eliminar estudio de movimiento",
|
||||
"cmd.motion_export": "Exportar estudio de movimiento",
|
||||
"cmd.motion_export_animation": "Exportar animación del movimiento",
|
||||
"cmd.motion_interference": "Interferencia de movimiento",
|
||||
"cmd.motion_list": "Listar estudios de movimiento",
|
||||
"cmd.motion_play": "Reproducir estudio de movimiento",
|
||||
"cmd.motion_plot": "Graficar estudio de movimiento",
|
||||
"cmd.motion_study": "Estudio de movimiento",
|
||||
"cmd.motion_trace": "Trayectoria de movimiento",
|
||||
"cmd.motion_trace_keep": "Conservar trayectoria de movimiento",
|
||||
@@ -453,7 +486,7 @@
|
||||
"cmd.parts_library": "Piezas normalizadas",
|
||||
"cmd.parts_resize": "Cambiar tamaño de pieza",
|
||||
"cmd.plastic_assign_rule": "Asignar regla de plástico",
|
||||
"cmd.plastic_boss": "Resalte",
|
||||
"cmd.plastic_boss": "Saliente",
|
||||
"cmd.plastic_lip": "Labio",
|
||||
"cmd.plastic_manage_rules": "Gestionar reglas de plástico",
|
||||
"cmd.plastic_rest": "Apoyo",
|
||||
@@ -545,7 +578,7 @@
|
||||
"cmd.sketch_slot_center_point": "Ranura por punto central",
|
||||
"cmd.sketch_slot_center_to_center": "Ranura entre centros",
|
||||
"cmd.sketch_slot_overall": "Ranura por longitud total",
|
||||
"cmd.sketch_snap": "Detección de referencias",
|
||||
"cmd.sketch_snap": "Referencia a objetos",
|
||||
"cmd.sketch_solve": "Resolver boceto",
|
||||
"cmd.sketch_spline3d": "Spline 3D",
|
||||
"cmd.sketch_spline_control_point": "Spline por puntos de control",
|
||||
@@ -570,7 +603,7 @@
|
||||
"cmd.surface_extrude": "Extruir superficie",
|
||||
"cmd.surface_fair": "Suavizar superficie",
|
||||
"cmd.surface_fillet": "Redondeo de superficie",
|
||||
"cmd.surface_loft": "Transición de superficies",
|
||||
"cmd.surface_loft": "Solevación de superficies",
|
||||
"cmd.surface_match": "Igualar superficie",
|
||||
"cmd.surface_offset": "Desfasar superficie",
|
||||
"cmd.surface_patch": "Parche",
|
||||
@@ -592,7 +625,7 @@
|
||||
"cmd.timeline_redefine": "Editar operación",
|
||||
"cmd.timeline_rename": "Cambiar nombre",
|
||||
"cmd.timeline_reorder": "Reordenar operación",
|
||||
"cmd.timeline_roll_to": "Desplazar marcador del historial",
|
||||
"cmd.timeline_roll_to": "Desplazar marcador de la línea de tiempo",
|
||||
"cmd.timeline_rollback": "Mover marcador de la línea de tiempo",
|
||||
"cmd.timeline_suppress": "Suprimir operación",
|
||||
"cmd.view_apply": "Restaurar vista guardada",
|
||||
@@ -631,6 +664,18 @@
|
||||
"drive.stopped_by_contact": "Detenido en {value}: {a} atravesaría {b}",
|
||||
"drive.stopped_by_loop": "Detenido en {value}: el bucle que pasa por {joint} no puede cerrarse más",
|
||||
"drive.unconnected": "No mueve nada: {part} no está conectado a ningún elemento fijado",
|
||||
"dxf.cancel_wait": "Esperando a que se detenga la exportación…",
|
||||
"dxf.cancelled": "Exportación DXF cancelada",
|
||||
"dxf.cancelling": "Cancelando la exportación DXF",
|
||||
"dxf.exporting": "Exportando DXF",
|
||||
"dxf.invalid_tolerance": "La tolerancia de curva debe estar entre 0,0001 y 1 mm.",
|
||||
"dxf.progress.index": "Preparando superficies (2/5)",
|
||||
"dxf.progress.meshes": "Preparando mallas (1/5)",
|
||||
"dxf.progress.occlusion": "Resolviendo líneas ocultas (4/5)",
|
||||
"dxf.progress.projection": "Proyectando geometría (3/5)",
|
||||
"dxf.progress.write": "Escribiendo DXF (5/5)",
|
||||
"dxf.tolerance": "Tolerancia de curva",
|
||||
"dxf.tolerance_help": "Solo cuerpos visibles. Las curvas usan segmentos rectos. Una tolerancia menor da curvas más finas y consume más tiempo y memoria.",
|
||||
"expression, e.g. 25 mm": "expresión, p. ej., 25 mm",
|
||||
"fits the face, e.g. M8": "se ajusta a la cara, p. ej., M8",
|
||||
"from the rule": "de la regla",
|
||||
@@ -662,6 +707,10 @@
|
||||
"joint.from_bodies_hint": "Cada cuerpo seleccionado pasa a un componente nuevo propio y conserva su posición.",
|
||||
"joint.one_body": "Las dos selecciones pertenecen a {body}. Selecciona la segunda en otra pieza.",
|
||||
"joint.one_component": "Estas piezas son cuerpos de un mismo componente: {bodies}. Una unión mueve un componente respecto a otro, por lo que cada pieza debe tener su propio componente.",
|
||||
"motion.checked_to": "Comprobado solo hasta el paso {step}: la comprobación agotó su presupuesto de trabajo",
|
||||
"motion.collision": "{a} · {b}: pasos {steps}",
|
||||
"motion.duration_hint": "segundos (vacío: por paso)",
|
||||
"motion.no_collisions": "Sin colisiones durante el estudio",
|
||||
"nav.touchpad.help": "Mantén pulsado Mayús y mueve el puntero para encuadrar; mantén Alt/Opción y muévelo para orbitar. No hace falta pulsar ningún botón del ratón. El desplazamiento vertical hace zoom.",
|
||||
"nav.touchpad.label": "Panel táctil",
|
||||
"nav.touchpad.orbit": "Orbitar (mantén Alt/Opción y mueve el puntero; sin pulsar botones)",
|
||||
@@ -724,17 +773,17 @@
|
||||
"render.use_preview": "Usar vista previa integrada",
|
||||
"sample.bracket.about": "Brida base con pestañas laterales y reborde, según una regla de 2 mm",
|
||||
"sample.bracket.name": "Soporte de chapa",
|
||||
"sample.enclosure.about": "Caja vaciada con labio, alojamientos para tornillos, apoyo y cierre a presión",
|
||||
"sample.enclosure.about": "Caja vaciada con labio, salientes para tornillos, apoyo y encaje a presión",
|
||||
"sample.enclosure.name": "Carcasa de plástico",
|
||||
"sample.flange.about": "Cubo y brida con patrón circular controlado por un parámetro",
|
||||
"sample.flange.name": "Brida atornillada",
|
||||
"sample.hinge.about": "Dos componentes y una unión de revolución accionada hasta 60°",
|
||||
"sample.hinge.name": "Conjunto de bisagra",
|
||||
"sample.plate.about": "Placa paramétrica con redondeos, agujeros y un resalte perforado",
|
||||
"sample.plate.about": "Placa paramétrica con redondeos, agujeros y un saliente perforado",
|
||||
"sample.plate.name": "Placa de ejemplo",
|
||||
"sample.vents.about": "Ranuras en un patrón rectangular: las filas, las columnas y el paso son parámetros",
|
||||
"sample.vents.name": "Placa de ventilación",
|
||||
"settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia la vista, nunca el diseño ni las exportaciones.",
|
||||
"settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia el visor, nunca el diseño ni las exportaciones.",
|
||||
"settings.frame_rate_note": "Muestra los fotogramas por segundo en la esquina inferior derecha de la vista. Mientras esté activado, la vista se redibuja continuamente.",
|
||||
"settings.measure_axis_distances": "Mostrar distancias X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Mostrar las cotas de cada eje global junto a la distancia total mientras está abierta la herramienta Medir.",
|
||||
@@ -760,6 +809,10 @@
|
||||
"sketch_text.path_hint": "Curva del boceto que seguirá el texto: haz clic aquí y después en la curva",
|
||||
"sketch_text.use_font_file": "Usar el archivo de fuente {path}",
|
||||
"the parts library is not available": "la biblioteca de piezas no está disponible",
|
||||
"thread.automatic_size": "Tamaño automático",
|
||||
"thread.designation": "Designación",
|
||||
"thread.iso_metric": "Métrica ISO",
|
||||
"thread.select_size": "Seleccionar tamaño",
|
||||
"timeline.group": "Nueva carpeta de operaciones",
|
||||
"timeline.move_many": "Mover elementos de la línea de tiempo",
|
||||
"timeline.move_to_group": "Mover operaciones a la carpeta",
|
||||
@@ -1005,6 +1058,10 @@
|
||||
"ui.construct": "Construir",
|
||||
"ui.construction": "Construcción",
|
||||
"ui.context_menu": "Menú contextual",
|
||||
"ui.continuity_g0": "G0 arista",
|
||||
"ui.continuity_g1": "G1 tangencia",
|
||||
"ui.continuity_g2": "G2 curvatura",
|
||||
"ui.continuity_gap": "Separación",
|
||||
"ui.copy": "Copiar",
|
||||
"ui.corner": "Esquina",
|
||||
"ui.corners": "Esquinas",
|
||||
@@ -1066,7 +1123,7 @@
|
||||
"ui.equivalent_radius": "Radio equivalente",
|
||||
"ui.expand": "Expandir",
|
||||
"ui.exportDxf": "Exportar DXF…",
|
||||
"ui.export_ellipsis": "Exportar...",
|
||||
"ui.export_ellipsis": "Exportar…",
|
||||
"ui.export_ellipsis_keymap": "Exportar…",
|
||||
"ui.export_keymap_tip": "Exportar las teclas, los favoritos de la búsqueda S y la barra de herramientas como archivo",
|
||||
"ui.extend": "Extender",
|
||||
@@ -1092,6 +1149,8 @@
|
||||
"ui.fully_constrained": "Totalmente restringido",
|
||||
"ui.general": "General",
|
||||
"ui.graphics": "Gráficos",
|
||||
"ui.grid_follows_view": "La cuadrícula sigue a la vista",
|
||||
"ui.grid_follows_view_hint": "Las vistas laterales muestran la cuadrícula en el plano vertical que tienen enfrente, en lugar del suelo",
|
||||
"ui.ground_shadow": "Sombra en el suelo",
|
||||
"ui.groupSelected": "Agrupar selección",
|
||||
"ui.heading_add_ins": "COMPLEMENTOS",
|
||||
@@ -1118,7 +1177,7 @@
|
||||
"ui.heading_home": "INICIO",
|
||||
"ui.heading_insert": "INSERTAR",
|
||||
"ui.heading_inspect": "INSPECCIONAR",
|
||||
"ui.heading_loft": "TRANSICIÓN",
|
||||
"ui.heading_loft": "SOLEVACIÓN",
|
||||
"ui.heading_make": "FABRICAR",
|
||||
"ui.heading_measure": "MEDIR",
|
||||
"ui.heading_mesh": "MALLA",
|
||||
@@ -1188,7 +1247,7 @@
|
||||
"ui.line": "Línea",
|
||||
"ui.linetype": "Tipo de línea",
|
||||
"ui.lock": "Bloquear",
|
||||
"ui.loft": "Transición",
|
||||
"ui.loft": "Solevación",
|
||||
"ui.look_at": "Mirar hacia",
|
||||
"ui.look_at_anim_desc": "Vista de eje, Mirar hacia, Inicio y Ajustar",
|
||||
"ui.manufacture": "Fabricación",
|
||||
@@ -1241,7 +1300,7 @@
|
||||
"ui.offset_plane": "Plano desfasado",
|
||||
"ui.one_side": "Un lado",
|
||||
"ui.open": "Abrir",
|
||||
"ui.open_ellipsis": "Abrir...",
|
||||
"ui.open_ellipsis": "Abrir…",
|
||||
"ui.open_preferences": "Abrir preferencias",
|
||||
"ui.open_recent": "Abrir reciente",
|
||||
"ui.operation": "Operación",
|
||||
@@ -1286,6 +1345,8 @@
|
||||
"ui.radial_context_menu": "Menú contextual radial",
|
||||
"ui.radius": "Radio",
|
||||
"ui.radius_type": "Tipo de radio",
|
||||
"ui.rail": "Línea guía",
|
||||
"ui.rail_fit": "Ajuste a la guía",
|
||||
"ui.reachable": "Alcanzable",
|
||||
"ui.rectangle": "Rectángulo",
|
||||
"ui.rectangular_pattern": "Patrón rectangular",
|
||||
@@ -1306,12 +1367,13 @@
|
||||
"ui.right": "Derecha",
|
||||
"ui.save": "Guardar",
|
||||
"ui.save_as": "Guardar como",
|
||||
"ui.save_as_ellipsis": "Guardar como...",
|
||||
"ui.save_as_ellipsis": "Guardar como…",
|
||||
"ui.save_mesh_no_slicer": "No se ha encontrado ningún laminador: elige Personalizado… y selecciona tu laminador, o desactiva Enviar al laminador",
|
||||
"ui.save_mesh_pick_bodies": "Selecciona los cuerpos que se van a guardar",
|
||||
"ui.save_mesh_temp_failed": "No se ha podido crear {folder}: {error}",
|
||||
"ui.saved_to": "Guardado en {path}",
|
||||
"ui.scale": "Escala",
|
||||
"ui.second_rail": "Segunda línea guía",
|
||||
"ui.section_analysis": "Análisis de sección",
|
||||
"ui.select": "Seleccionar",
|
||||
"ui.selectOther": "Seleccionar otro…",
|
||||
@@ -1321,6 +1383,7 @@
|
||||
"ui.send": "Enviar",
|
||||
"ui.send_to_slicer": "Enviar al laminador",
|
||||
"ui.sent_to_slicer": "Enviado a {slicer}",
|
||||
"ui.sheet_gap": "Separación",
|
||||
"ui.shell": "Vaciado",
|
||||
"ui.show": "Mostrar",
|
||||
"ui.showAll": "Mostrar todo",
|
||||
@@ -1351,7 +1414,7 @@
|
||||
"ui.smooth": "Suave",
|
||||
"ui.smooth_wheel_zoom": "Zoom suave con la rueda",
|
||||
"ui.smooth_wheel_zoom_tip": "Desactívalo para aplicar cada movimiento de la rueda inmediatamente, sin suavizado posterior",
|
||||
"ui.snap": "Ajustar a referencias",
|
||||
"ui.snap": "Referencia a objetos",
|
||||
"ui.space_mouse_hint": "Desplaza y gira la vista a la vez; los bocetos solo permiten desplazamiento y zoom.",
|
||||
"ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse",
|
||||
"ui.spacing": "Espaciado",
|
||||
@@ -1362,6 +1425,7 @@
|
||||
"ui.stand_up_tip": "Tras abrir un archivo STEP o IGES con Z hacia arriba, ofrecer un cuarto de vuelta alrededor de X",
|
||||
"ui.start": "Iniciar",
|
||||
"ui.start_radius": "Radio inicial",
|
||||
"ui.stretch": "Estirar",
|
||||
"ui.supersampling": "Supermuestreo (SSAA)",
|
||||
"ui.supersampling_adaptive": "Solo cuando la vista está inmóvil",
|
||||
"ui.supersampling_off": "Desactivado (1×)",
|
||||
|
||||
+22
-1
@@ -86,6 +86,7 @@
|
||||
"Density": "Masse volumique",
|
||||
"Designs you open or save appear here.": "Les conceptions que vous ouvrez ou enregistrez apparaissent ici.",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "Déterminé par Jusqu'à l'objet. Choisir Angle pour modifier l'étendue angulaire.",
|
||||
"Direction": "Direction",
|
||||
"Discard": "Abandonner",
|
||||
"Do not export hidden edges": "Ne pas exporter les arêtes cachées",
|
||||
"Don't ask again": "Ne plus demander",
|
||||
@@ -98,6 +99,7 @@
|
||||
"Enable rule": "Activer la règle",
|
||||
"Enabled": "Activé",
|
||||
"Explode": "Éclater",
|
||||
"Explode Text": "Décomposer le texte",
|
||||
"Export Animation…": "Exporter l'animation…",
|
||||
"Export Current View as DXF": "Exporter la vue actuelle en DXF",
|
||||
"Export Current View as DXF…": "Exporter la vue actuelle en DXF…",
|
||||
@@ -118,7 +120,6 @@
|
||||
"Follow the camera (Look At sketches)": "Suivre la caméra (vue normale aux esquisses)",
|
||||
"Font": "Police",
|
||||
"Full Length": "Longueur totale",
|
||||
"Gap": "Jeu",
|
||||
"Grid": "Grille",
|
||||
"Height": "Hauteur",
|
||||
"Hidden edges": "Arêtes cachées",
|
||||
@@ -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",
|
||||
@@ -156,11 +158,13 @@
|
||||
"Move earlier": "Déplacer plus tôt",
|
||||
"Move later": "Déplacer plus tard",
|
||||
"Name": "Nom",
|
||||
"Nearest": "Corps le plus proche",
|
||||
"New study": "Nouvelle étude",
|
||||
"No crash reports.": "Aucun rapport de plantage.",
|
||||
"No matching appearances": "Aucune apparence correspondante",
|
||||
"No visible bodies to export.": "Aucun corps visible à exporter.",
|
||||
"None": "Aucun",
|
||||
"Normal to sketch": "Normale à l'esquisse",
|
||||
"Not available yet": "Pas encore disponible",
|
||||
"Notch": "Encoche",
|
||||
"Opacity": "Opacité",
|
||||
@@ -274,6 +278,7 @@
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Cette face ne peut pas fournir de rattachement durable. Choisissez une autre face ou utilisez explicitement un plan fixe.",
|
||||
"To Offset": "Jusqu'au décalage",
|
||||
"Tolerance": "Tolérance",
|
||||
"Too close": "Trop proches",
|
||||
"Turn off section analysis before exporting the view.": "Désactivez l'analyse de coupe avant d'exporter la vue.",
|
||||
"Type": "Type",
|
||||
"UNTRIM": "ANNULER L'AJUSTEMENT",
|
||||
@@ -407,6 +412,7 @@
|
||||
"cmd.file_slicers": "Slicers installés",
|
||||
"cmd.inspect_accessibility": "Analyse d'accessibilité",
|
||||
"cmd.inspect_center_of_mass": "Centre de gravité",
|
||||
"cmd.inspect_clearance": "Jeu",
|
||||
"cmd.inspect_compare": "Comparer les corps",
|
||||
"cmd.inspect_continuity": "Analyse de continuité",
|
||||
"cmd.inspect_curvature_comb": "Analyse par peigne de courbure",
|
||||
@@ -416,6 +422,7 @@
|
||||
"cmd.inspect_interference": "Interférence",
|
||||
"cmd.inspect_isocurve": "Analyse des isocourbes",
|
||||
"cmd.inspect_minimum_radius": "Analyse du rayon minimal",
|
||||
"cmd.inspect_silhouette": "Silhouette",
|
||||
"cmd.inspect_zebra": "Analyse zébrée",
|
||||
"cmd.joint_as_built": "Liaison telle que construite",
|
||||
"cmd.joint_cam": "Came",
|
||||
@@ -644,7 +651,12 @@
|
||||
"coupling.rack": "Crémaillère",
|
||||
"coupling.radius_1": "Rayon 1",
|
||||
"coupling.radius_2": "Rayon 2",
|
||||
"dialog.bodies_count": "{count} corps",
|
||||
"dialog.clearance_invalid": "le jeu doit être une longueur positive ou nulle",
|
||||
"dialog.compute": "Calculer",
|
||||
"dialog.compute_again": "Recalculer",
|
||||
"dialog.computing": "Calcul… {seconds} s",
|
||||
"dialog.could_not_be_checked": "non vérifiable",
|
||||
"dialog.custom_axis": "↖ Axe personnalisé",
|
||||
"dialog.edit_title": "MODIFIER {title}",
|
||||
"dialog.select": "↖ Sélectionner",
|
||||
@@ -1000,6 +1012,7 @@
|
||||
"ui.base_point": "Point de base",
|
||||
"ui.blocked": "Inaccessible",
|
||||
"ui.bodies": "Corps",
|
||||
"ui.bodies_all_if_none": "Corps (tous si aucun)",
|
||||
"ui.box": "Boîte",
|
||||
"ui.break": "Fractionner",
|
||||
"ui.calibrate": "Étalonner",
|
||||
@@ -1051,6 +1064,10 @@
|
||||
"ui.construct": "Construire",
|
||||
"ui.construction": "Construction",
|
||||
"ui.context_menu": "Menu contextuel",
|
||||
"ui.continuity_g0": "G0 angle vif",
|
||||
"ui.continuity_g1": "G1 tangence",
|
||||
"ui.continuity_g2": "G2 courbure",
|
||||
"ui.continuity_gap": "Écart",
|
||||
"ui.copy": "Copier",
|
||||
"ui.corner": "Angle",
|
||||
"ui.corners": "Angles",
|
||||
@@ -1138,6 +1155,8 @@
|
||||
"ui.fully_constrained": "Entièrement contrainte",
|
||||
"ui.general": "Général",
|
||||
"ui.graphics": "Graphisme",
|
||||
"ui.grid_follows_view": "La grille suit la vue",
|
||||
"ui.grid_follows_view_hint": "Dans les vues de côté, la grille s'affiche sur le plan vertical qui leur fait face plutôt que sur le sol",
|
||||
"ui.ground_shadow": "Ombre au sol",
|
||||
"ui.groupSelected": "Grouper la sélection",
|
||||
"ui.heading_add_ins": "COMPLÉMENTS",
|
||||
@@ -1164,6 +1183,7 @@
|
||||
"ui.heading_home": "ACCUEIL",
|
||||
"ui.heading_insert": "INSÉRER",
|
||||
"ui.heading_inspect": "INSPECTER",
|
||||
"ui.heading_interference": "INTERFÉRENCE",
|
||||
"ui.heading_loft": "LISSAGE",
|
||||
"ui.heading_make": "FABRIQUER",
|
||||
"ui.heading_measure": "MESURER",
|
||||
@@ -1370,6 +1390,7 @@
|
||||
"ui.send": "Envoyer",
|
||||
"ui.send_to_slicer": "Envoyer au slicer",
|
||||
"ui.sent_to_slicer": "Envoyé à {slicer}",
|
||||
"ui.sheet_gap": "Jeu",
|
||||
"ui.shell": "Coque",
|
||||
"ui.show": "Afficher",
|
||||
"ui.showAll": "Tout afficher",
|
||||
|
||||
@@ -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",
|
||||
@@ -112,7 +113,6 @@
|
||||
"Follow the camera (Look At sketches)": "Seguir a cámara (Mirar cara aos bosquexos)",
|
||||
"Font": "Tipo de letra",
|
||||
"Full Length": "Lonxitude completa",
|
||||
"Gap": "Separación",
|
||||
"Grid": "Grella",
|
||||
"Height": "Altura",
|
||||
"Hidden edges": "Arestas ocultas",
|
||||
@@ -121,6 +121,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 +155,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",
|
||||
@@ -1028,6 +1030,7 @@
|
||||
"ui.construct": "Construír",
|
||||
"ui.construction": "Construción",
|
||||
"ui.context_menu": "Menú contextual",
|
||||
"ui.continuity_gap": "Separación",
|
||||
"ui.copy": "Copiar",
|
||||
"ui.corner": "Esquina",
|
||||
"ui.corners": "Esquinas",
|
||||
@@ -1344,6 +1347,7 @@
|
||||
"ui.send": "Enviar",
|
||||
"ui.send_to_slicer": "Enviar ao laminador",
|
||||
"ui.sent_to_slicer": "Enviado a {slicer}",
|
||||
"ui.sheet_gap": "Separación",
|
||||
"ui.shell": "Baleirado",
|
||||
"ui.show": "Mostrar",
|
||||
"ui.showAll": "Mostrar todo",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+61
-1
@@ -31,6 +31,7 @@
|
||||
"Arc length": "円弧の長さ",
|
||||
"Area": "表面積",
|
||||
"Attachment": "アタッチメント",
|
||||
"BOUNDARY SURFACE": "境界サーフェス",
|
||||
"BROWSER": "ブラウザ",
|
||||
"Bend Line Position": "曲げ線位置",
|
||||
"Bend Position": "曲げ位置",
|
||||
@@ -73,6 +74,7 @@
|
||||
"Counterbore depth": "座ぐり深さ",
|
||||
"Countersink angle": "皿穴角度",
|
||||
"Create Issue — review draft": "Issueを作成 — 下書きの確認",
|
||||
"Cross curves": "交差する曲線",
|
||||
"Cube": "キューブ",
|
||||
"Current orthographic view (1:1 mm)": "現在の平行投影(実寸・mm)",
|
||||
"Current perspective view": "現在の透視投影",
|
||||
@@ -86,6 +88,7 @@
|
||||
"Density": "密度",
|
||||
"Designs you open or save appear here.": "開いたデザインや保存したデザインがここに表示されます。",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "「対象オブジェクトまで」により決定されます。角度範囲を編集するには「角度」を選択してください。",
|
||||
"Direction": "方向",
|
||||
"Discard": "破棄",
|
||||
"Do not export hidden edges": "出力しない",
|
||||
"Don't ask again": "今後確認しない",
|
||||
@@ -119,7 +122,6 @@
|
||||
"Follow the camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
|
||||
"Font": "フォント",
|
||||
"Full Length": "全長",
|
||||
"Gap": "ギャップ",
|
||||
"Grid": "グリッド",
|
||||
"Height": "高さ",
|
||||
"Hidden edges": "隠線",
|
||||
@@ -128,6 +130,7 @@
|
||||
"Home view": "ホームビュー",
|
||||
"In This Design": "このデザイン内",
|
||||
"In place (surfaces)": "その場で(サーフェス)",
|
||||
"In plane": "平面内",
|
||||
"Inside": "内側",
|
||||
"Interferes": "干渉あり",
|
||||
"Issue title": "Issueタイトル",
|
||||
@@ -163,6 +166,7 @@
|
||||
"No matching appearances": "一致する外観がありません",
|
||||
"No visible bodies to export.": "出力する表示中のボディがありません。",
|
||||
"None": "なし",
|
||||
"Normal to sketch": "スケッチに垂直",
|
||||
"Not available yet": "まだ利用できません",
|
||||
"Notch": "ノッチ",
|
||||
"Opacity": "不透明度",
|
||||
@@ -555,6 +559,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": "スケッチ点をドラッグ",
|
||||
@@ -649,7 +655,12 @@
|
||||
"coupling.rack": "ラック",
|
||||
"coupling.radius_1": "半径 1",
|
||||
"coupling.radius_2": "半径 2",
|
||||
"dialog.bodies_count": "{count} ボディ",
|
||||
"dialog.clearance_invalid": "クリアランスは 0 以上の長さにしてください",
|
||||
"dialog.compute": "計算",
|
||||
"dialog.compute_again": "再計算",
|
||||
"dialog.computing": "計算中… {seconds} 秒",
|
||||
"dialog.could_not_be_checked": "チェックできません",
|
||||
"dialog.custom_axis": "↖ カスタム軸",
|
||||
"dialog.edit_title": "{title}を編集",
|
||||
"dialog.select": "↖ 選択",
|
||||
@@ -719,9 +730,11 @@
|
||||
"new configuration": "新規構成",
|
||||
"new name": "新しい名前",
|
||||
"new parameter": "新規パラメータ",
|
||||
"no hole": "穴なし",
|
||||
"no interference": "干渉なし",
|
||||
"no joint that moves yet": "可動ジョイントがまだありません",
|
||||
"no picture": "画像なし",
|
||||
"none": "なし",
|
||||
"pattern.alignment_2": "配置 2",
|
||||
"pattern.alignment_hint": "個数には元の図形が含まれます。中央配置ではコピーを両側に配置し、反転で偶数個の場合に多く配置する側を切り替えます。",
|
||||
"pattern.direction_1": "方向 1",
|
||||
@@ -794,6 +807,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": "右",
|
||||
@@ -806,6 +821,7 @@
|
||||
"sketch_text.path_clear": "文字を曲線から外す",
|
||||
"sketch_text.path_hint": "文字が沿うスケッチの曲線: ここをクリックしてから曲線をクリック",
|
||||
"sketch_text.use_font_file": "フォント ファイル {path} を使用",
|
||||
"solid": "中実",
|
||||
"the parts library is not available": "パーツライブラリは利用できません",
|
||||
"thread.automatic_size": "サイズを自動選択",
|
||||
"thread.designation": "ねじの呼び",
|
||||
@@ -817,6 +833,7 @@
|
||||
"timeline.rename_group": "フィーチャーフォルダの名前を変更",
|
||||
"timeline.reorder_many": "フィーチャーの順序を変更",
|
||||
"timeline.ungroup": "フィーチャーフォルダを削除",
|
||||
"to the face": "面まで",
|
||||
"to the next face": "次の面まで",
|
||||
"toolbar.image": "画像",
|
||||
"toolbar.render": "レンダリング",
|
||||
@@ -832,12 +849,15 @@
|
||||
"ui.activate": "アクティブ化",
|
||||
"ui.active_component": "アクティブなコンポーネント",
|
||||
"ui.agent_chat": "エージェントチャット",
|
||||
"ui.agent_chat_add": "ファイルを添付",
|
||||
"ui.agent_chat_add_file": "ファイルを選択…",
|
||||
"ui.agent_chat_allow": "許可",
|
||||
"ui.agent_chat_allowed": "許可しました",
|
||||
"ui.agent_chat_answered": "回答: {option}",
|
||||
"ui.agent_chat_approval": "エージェントにこの操作を許可しますか?",
|
||||
"ui.agent_chat_attach_view": "ビューを添付",
|
||||
"ui.agent_chat_attach_view_tip": "現在のビューの画像をメッセージと一緒に送ります",
|
||||
"ui.agent_chat_attachable": "画像・テキスト・CAD ファイル",
|
||||
"ui.agent_chat_auth_agent": "{name} でサインイン",
|
||||
"ui.agent_chat_auth_env": "環境変数 {vars} を設定して SolidCraft を再起動してください。",
|
||||
"ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。SolidCraft がパスワードやキーを見ることはありません。",
|
||||
@@ -857,11 +877,33 @@
|
||||
"ui.agent_chat_deny": "拒否",
|
||||
"ui.agent_chat_desktop_only": "エージェントチャットにはデスクトップアプリが必要です。",
|
||||
"ui.agent_chat_detecting": "エージェントを検索中…",
|
||||
"ui.agent_chat_dock": "サイドパネルに戻す",
|
||||
"ui.agent_chat_dock_left": "左にドッキング",
|
||||
"ui.agent_chat_dock_right": "右にドッキング",
|
||||
"ui.agent_chat_drop_hint": "ドロップして次のメッセージに添付",
|
||||
"ui.agent_chat_edit": "編集",
|
||||
"ui.agent_chat_exited": "エージェントが停止しました。",
|
||||
"ui.agent_chat_failed_start": "エージェントを起動できませんでした: {error}",
|
||||
"ui.agent_chat_file_cad_not_read": "{name}: CAD ファイルは読み込まず、デザインにインポートします",
|
||||
"ui.agent_chat_file_empty_picture": "{name}: 空の画像です",
|
||||
"ui.agent_chat_file_error": "{name}: {error}",
|
||||
"ui.agent_chat_file_heic": "{name}: HEIC 画像はここでは読めません。先に JPEG か PNG に書き出してください",
|
||||
"ui.agent_chat_file_importing": "デザインにインポート中…",
|
||||
"ui.agent_chat_file_kinds": "{name}: 添付できるのは画像 (PNG、JPEG、GIF、WebP、BMP、TIFF)、テキストファイル (SVG も)、CAD ファイル (STEP、IGES、FreeCAD、Rhino、STL、3MF、OBJ、glTF) です",
|
||||
"ui.agent_chat_file_loading": "ファイルを読み込み中…",
|
||||
"ui.agent_chat_file_not_a_file": "{name}: ファイルではありません",
|
||||
"ui.agent_chat_file_not_a_picture": "{name}: 画像ではありません",
|
||||
"ui.agent_chat_file_not_utf8": "{name}: UTF-8 のテキストではありません",
|
||||
"ui.agent_chat_file_picture_too_big": "{name}: 画像は {max} MB までです",
|
||||
"ui.agent_chat_file_text_too_big": "{name}: テキストファイルは {max} KB までです",
|
||||
"ui.agent_chat_file_too_large": "{name}: 大きすぎます",
|
||||
"ui.agent_chat_file_too_large_to_send": "{name}: 縮小しても大きすぎて送れません",
|
||||
"ui.agent_chat_file_unreadable_picture": "{name}: 読み込めない画像です",
|
||||
"ui.agent_chat_files": "ファイル: {files}",
|
||||
"ui.agent_chat_files_left_out": "1 つのメッセージで送れるファイルは {max} 個までです。送らなかったファイル: {files}",
|
||||
"ui.agent_chat_files_loading": "ファイルの読み込みを待っています",
|
||||
"ui.agent_chat_files_max": "1 つのメッセージに添付できるのは {max} 個までです",
|
||||
"ui.agent_chat_import_failed": "{name}: インポートに失敗しました: {error}",
|
||||
"ui.agent_chat_intro": "普段使っているコーディングエージェント、または選んだモデルのエンドポイントとチャットできます。SolidCraft のツールで開いているデザインを操作し、変更はその場で表示され、メッセージごとに1回で元に戻せます。確認なしで許可する範囲は「環境設定 > AI」で設定します。",
|
||||
"ui.agent_chat_key_in_env": "キー設定済み",
|
||||
"ui.agent_chat_left": "左",
|
||||
@@ -874,14 +916,18 @@
|
||||
"ui.agent_chat_no_cli": "アプリの隣に solidcraft-cli が見つからないため、エージェントはデザインにアクセスできません。",
|
||||
"ui.agent_chat_no_control": "エージェントチャットのエンドポイントが起動しなかったため、エージェントはデザインにアクセスできません。",
|
||||
"ui.agent_chat_no_images": "このエージェントは画像を受け付けません",
|
||||
"ui.agent_chat_no_picture": "クリップボードに画像がありません",
|
||||
"ui.agent_chat_none_found": "コーディングエージェントが見つかりません。次のいずれかをインストールしてから「再検索」を押してください:",
|
||||
"ui.agent_chat_none_found_short": "エージェントが見つかりません",
|
||||
"ui.agent_chat_not_installed": "未インストール",
|
||||
"ui.agent_chat_not_signed_in": "未サインイン",
|
||||
"ui.agent_chat_own_window": "独立したウィンドウ",
|
||||
"ui.agent_chat_paste_picture": "画像を貼り付け",
|
||||
"ui.agent_chat_permission": "エージェントが実行の許可を求めています: {title}",
|
||||
"ui.agent_chat_pick": "エージェント",
|
||||
"ui.agent_chat_placeholder": "デザインの変更をエージェントに依頼…",
|
||||
"ui.agent_chat_placeholder_busy": "エージェントの作業が終わったら送るメッセージを入力(Ctrl+Enter: 今すぐ送信)",
|
||||
"ui.agent_chat_pop_out": "独立したウィンドウで開く(別の画面に移動できます)",
|
||||
"ui.agent_chat_privacy": "あなたのアカウントで実行するエージェント以外には、何も送信されません。",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。SolidCraft がキーやパスワードを見たり保存したりすることはありません。",
|
||||
"ui.agent_chat_queue": "待機に追加",
|
||||
@@ -891,6 +937,8 @@
|
||||
"ui.agent_chat_refresh": "再検索",
|
||||
"ui.agent_chat_refused": "エージェントには許可されていません: {reason}",
|
||||
"ui.agent_chat_remove": "待機から削除",
|
||||
"ui.agent_chat_remove_file": "このメッセージから外す",
|
||||
"ui.agent_chat_remove_imported": "このメッセージから外します。パーツはデザインに残ります (元に戻すで削除できます)",
|
||||
"ui.agent_chat_right": "右",
|
||||
"ui.agent_chat_save": "保存",
|
||||
"ui.agent_chat_send": "送信",
|
||||
@@ -1005,6 +1053,7 @@
|
||||
"ui.base_point": "基点",
|
||||
"ui.blocked": "到達不可",
|
||||
"ui.bodies": "ボディ",
|
||||
"ui.bodies_all_if_none": "ボディ(未選択ならすべて)",
|
||||
"ui.box": "直方体",
|
||||
"ui.break": "分割",
|
||||
"ui.calibrate": "校正",
|
||||
@@ -1059,6 +1108,7 @@
|
||||
"ui.continuity_g0": "G0 折れ",
|
||||
"ui.continuity_g1": "G1 接線",
|
||||
"ui.continuity_g2": "G2 曲率",
|
||||
"ui.continuity_gap": "隙間",
|
||||
"ui.copy": "コピー",
|
||||
"ui.corner": "コーナー",
|
||||
"ui.corners": "コーナー",
|
||||
@@ -1146,6 +1196,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": "アドイン",
|
||||
@@ -1172,6 +1224,7 @@
|
||||
"ui.heading_home": "ホーム",
|
||||
"ui.heading_insert": "挿入",
|
||||
"ui.heading_inspect": "検査",
|
||||
"ui.heading_interference": "干渉",
|
||||
"ui.heading_loft": "ロフト",
|
||||
"ui.heading_make": "製造",
|
||||
"ui.heading_measure": "計測",
|
||||
@@ -1296,6 +1349,7 @@
|
||||
"ui.one_side": "片側",
|
||||
"ui.open": "開く",
|
||||
"ui.open_ellipsis": "開く…",
|
||||
"ui.open_ends_tip": "どこにも接していない",
|
||||
"ui.open_preferences": "環境設定を開く",
|
||||
"ui.open_recent": "最近使った項目を開く",
|
||||
"ui.operation": "操作",
|
||||
@@ -1357,6 +1411,8 @@
|
||||
"ui.reset_the_settings_on_this_page": "このページの設定をリセット",
|
||||
"ui.restore": "復元",
|
||||
"ui.restore_defaults": "初期値に戻す",
|
||||
"ui.reverse_horizontal_pan_direction": "水平パンの方向を反転",
|
||||
"ui.reverse_horizontal_pan_tip": "水平方向の2本指スクロールとサムホイールで、反対方向にパンします",
|
||||
"ui.reverse_zoom_direction": "ズーム方向を反転",
|
||||
"ui.revolve": "回転",
|
||||
"ui.right": "右側面",
|
||||
@@ -1378,6 +1434,7 @@
|
||||
"ui.send": "送信",
|
||||
"ui.send_to_slicer": "スライサーに送る",
|
||||
"ui.sent_to_slicer": "{slicer} に送信しました",
|
||||
"ui.sheet_gap": "ギャップ",
|
||||
"ui.shell": "シェル",
|
||||
"ui.show": "表示",
|
||||
"ui.showAll": "すべて表示",
|
||||
@@ -1438,6 +1495,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 +1519,7 @@
|
||||
"ui.units_of_new_designs": "新規デザインの単位",
|
||||
"ui.unlock": "ロック解除",
|
||||
"ui.unsuppress": "抑制を解除",
|
||||
"ui.untied_ends_tip": "位置だけで接している",
|
||||
"ui.untitled": "無題",
|
||||
"ui.up_axis_note": "ビュー(軸ビュー、ホーム、正面視)で上向きにする軸を指定します。デザインデータ自体は変更されません。",
|
||||
"ui.updateToCurrentView": "現在のビューに更新",
|
||||
|
||||
+12
-1
@@ -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": "조인트 구동",
|
||||
@@ -42,11 +43,11 @@
|
||||
"Faces": "면",
|
||||
"Find in Browser": "브라우저에서 찾기",
|
||||
"Flat": "플랫",
|
||||
"Gap": "틈",
|
||||
"Hidden edges": "숨겨진 모서리",
|
||||
"Holes": "구멍",
|
||||
"Holes (edges on them)": "구멍 (구멍의 모서리)",
|
||||
"In place (surfaces)": "제자리에서 (서피스)",
|
||||
"In plane": "평면 내",
|
||||
"Inside": "안쪽",
|
||||
"Length": "길이",
|
||||
"Max": "최대",
|
||||
@@ -55,6 +56,7 @@
|
||||
"New study": "새 연구",
|
||||
"No visible bodies to export.": "표시된 바디가 없어 내보낼 수 없습니다.",
|
||||
"None": "없음",
|
||||
"Normal to sketch": "스케치에 수직",
|
||||
"Notch": "노치",
|
||||
"Outside": "바깥쪽",
|
||||
"Overall length": "전체 길이",
|
||||
@@ -445,7 +447,12 @@
|
||||
"coupling.rack": "랙",
|
||||
"coupling.radius_1": "반지름 1",
|
||||
"coupling.radius_2": "반지름 2",
|
||||
"dialog.bodies_count": "바디 {count}개",
|
||||
"dialog.clearance_invalid": "간격은 0 이상의 길이여야 합니다",
|
||||
"dialog.compute": "계산",
|
||||
"dialog.compute_again": "다시 계산",
|
||||
"dialog.computing": "계산 중… {seconds}초",
|
||||
"dialog.could_not_be_checked": "확인할 수 없음",
|
||||
"dialog.custom_axis": "↖ 사용자 정의 축",
|
||||
"dialog.edit_title": "{title} 편집",
|
||||
"dialog.select": "↖ 선택",
|
||||
@@ -766,6 +773,7 @@
|
||||
"ui.base_point": "기준점",
|
||||
"ui.blocked": "막힘",
|
||||
"ui.bodies": "바디",
|
||||
"ui.bodies_all_if_none": "바디 (없으면 전체)",
|
||||
"ui.box": "박스",
|
||||
"ui.break": "끊기",
|
||||
"ui.calibrate": "캘리브레이션",
|
||||
@@ -816,6 +824,7 @@
|
||||
"ui.continuity_g0": "G0 꺾임",
|
||||
"ui.continuity_g1": "G1 접선",
|
||||
"ui.continuity_g2": "G2 곡률",
|
||||
"ui.continuity_gap": "틈",
|
||||
"ui.copy": "복사",
|
||||
"ui.corner": "코너",
|
||||
"ui.corners": "코너",
|
||||
@@ -925,6 +934,7 @@
|
||||
"ui.heading_home": "홈",
|
||||
"ui.heading_insert": "삽입",
|
||||
"ui.heading_inspect": "검사",
|
||||
"ui.heading_interference": "간섭",
|
||||
"ui.heading_loft": "로프트",
|
||||
"ui.heading_measure": "측정",
|
||||
"ui.heading_mesh": "메시",
|
||||
@@ -1115,6 +1125,7 @@
|
||||
"ui.send": "보내기",
|
||||
"ui.send_to_slicer": "슬라이서로 보내기",
|
||||
"ui.sent_to_slicer": "{slicer}(으)로 보냈습니다",
|
||||
"ui.sheet_gap": "틈",
|
||||
"ui.shell": "쉘",
|
||||
"ui.show": "표시",
|
||||
"ui.showAll": "모두 표시",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
@@ -112,7 +113,6 @@
|
||||
"Follow the camera (Look At sketches)": "Seguir a câmera (Olhar para os esboços)",
|
||||
"Font": "Fonte",
|
||||
"Full Length": "Comprimento total",
|
||||
"Gap": "Folga",
|
||||
"Grid": "Grade",
|
||||
"Height": "Altura",
|
||||
"Hidden edges": "Arestas ocultas",
|
||||
@@ -121,6 +121,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 +155,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",
|
||||
@@ -1008,6 +1010,7 @@
|
||||
"ui.construct": "Construir",
|
||||
"ui.construction": "Construção",
|
||||
"ui.context_menu": "Menu de contexto",
|
||||
"ui.continuity_gap": "Folga",
|
||||
"ui.copy": "Copiar",
|
||||
"ui.corner": "Canto",
|
||||
"ui.corners": "Cantos",
|
||||
@@ -1324,6 +1327,7 @@
|
||||
"ui.send": "Enviar",
|
||||
"ui.send_to_slicer": "Enviar ao fatiador",
|
||||
"ui.sent_to_slicer": "Enviado para {slicer}",
|
||||
"ui.sheet_gap": "Folga",
|
||||
"ui.shell": "Casca",
|
||||
"ui.show": "Mostrar",
|
||||
"ui.showAll": "Mostrar tudo",
|
||||
|
||||
@@ -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": "Двойной щелчок средней кнопкой в виде: «Главная» (главный вид, кадрирует показанное), «Вписать» (кадрирует, сохраняя ориентацию) или ничего",
|
||||
@@ -101,11 +102,11 @@
|
||||
"Follow the camera (Look At sketches)": "Следовать за камерой (эскизы «Взгляд на»)",
|
||||
"Font": "Шрифт",
|
||||
"Full Length": "Полная длина",
|
||||
"Gap": "Зазор",
|
||||
"Grid": "Сетка",
|
||||
"Height": "Высота",
|
||||
"Home view": "Главный вид",
|
||||
"In This Design": "В этом дизайне",
|
||||
"In plane": "В плоскости",
|
||||
"Interferes": "Пересекается",
|
||||
"Issue title": "Заголовок Issue",
|
||||
"Joint Origin1": "Точка сопряжения1",
|
||||
@@ -134,6 +135,7 @@
|
||||
"New study": "Новое исследование",
|
||||
"No crash reports.": "Нет отчётов о сбоях.",
|
||||
"No matching appearances": "Нет подходящих внешних видов",
|
||||
"Normal to sketch": "По нормали к эскизу",
|
||||
"Not available yet": "Пока недоступно",
|
||||
"Notch": "Вырез",
|
||||
"Opacity": "Непрозрачность",
|
||||
@@ -815,6 +817,7 @@
|
||||
"ui.construct": "Построение",
|
||||
"ui.construction": "Вспомогательная",
|
||||
"ui.context_menu": "Контекстное меню",
|
||||
"ui.continuity_gap": "Зазор",
|
||||
"ui.copy": "Копировать",
|
||||
"ui.corner": "Угол",
|
||||
"ui.corners": "Углы",
|
||||
@@ -1123,6 +1126,7 @@
|
||||
"ui.send": "Отправить",
|
||||
"ui.send_to_slicer": "Отправить в слайсер",
|
||||
"ui.sent_to_slicer": "Отправлено в {slicer}",
|
||||
"ui.sheet_gap": "Зазор",
|
||||
"ui.shell": "Оболочка",
|
||||
"ui.show": "Показать",
|
||||
"ui.showAll": "Показать всё",
|
||||
|
||||
@@ -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",
|
||||
@@ -106,7 +107,6 @@
|
||||
"Follow the camera (Look At sketches)": "Kamera takibi (eskizlerde Bak)",
|
||||
"Font": "Yazı tipi",
|
||||
"Full Length": "Tam uzunluk",
|
||||
"Gap": "Boşluk",
|
||||
"Grid": "Izgara",
|
||||
"Height": "Yükseklik",
|
||||
"Holes": "Delikler",
|
||||
@@ -114,6 +114,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 +148,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ç",
|
||||
@@ -957,6 +959,7 @@
|
||||
"ui.construct": "Yapı",
|
||||
"ui.construction": "Yapı çizgisi",
|
||||
"ui.context_menu": "Bağlam menüsü",
|
||||
"ui.continuity_gap": "Boşluk",
|
||||
"ui.copy": "Kopyala",
|
||||
"ui.corner": "Köşe",
|
||||
"ui.corners": "Köşeler",
|
||||
@@ -1271,6 +1274,7 @@
|
||||
"ui.send": "Gönder",
|
||||
"ui.send_to_slicer": "Dilimleyiciye gönder",
|
||||
"ui.sent_to_slicer": "{slicer} uygulamasına gönderildi",
|
||||
"ui.sheet_gap": "Boşluk",
|
||||
"ui.shell": "Kabuk",
|
||||
"ui.show": "Göster",
|
||||
"ui.showAll": "Tümünü göster",
|
||||
|
||||
@@ -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": "Двічі клацніть, щоб перейменувати",
|
||||
@@ -117,7 +118,6 @@
|
||||
"Follow the camera (Look At sketches)": "Стежити за камерою (ескізи наведення)",
|
||||
"Font": "Шрифт",
|
||||
"Full Length": "Повна довжина",
|
||||
"Gap": "Проміжок",
|
||||
"Grid": "Сітка",
|
||||
"Height": "Висота",
|
||||
"Hidden edges": "Приховані ребра",
|
||||
@@ -126,6 +126,7 @@
|
||||
"Home view": "Головний вид",
|
||||
"In This Design": "У цьому проєкті",
|
||||
"In place (surfaces)": "На місці (поверхні)",
|
||||
"In plane": "У площині",
|
||||
"Inside": "Усередину",
|
||||
"Interferes": "Перетинається",
|
||||
"Issue title": "Заголовок повідомлення",
|
||||
@@ -160,6 +161,7 @@
|
||||
"No matching appearances": "Немає відповідних виглядів",
|
||||
"No visible bodies to export.": "Немає видимих тіл для експорту.",
|
||||
"None": "Немає",
|
||||
"Normal to sketch": "За нормаллю до ескізу",
|
||||
"Not available yet": "Ще недоступно",
|
||||
"Notch": "Виріз",
|
||||
"Opacity": "Непрозорість",
|
||||
@@ -1031,6 +1033,7 @@
|
||||
"ui.construct": "Побудувати",
|
||||
"ui.construction": "Конструкційна",
|
||||
"ui.context_menu": "Контекстне меню",
|
||||
"ui.continuity_gap": "Проміжок",
|
||||
"ui.copy": "Копіювати",
|
||||
"ui.corner": "Кут",
|
||||
"ui.corners": "Кути",
|
||||
@@ -1347,6 +1350,7 @@
|
||||
"ui.send": "Надіслати",
|
||||
"ui.send_to_slicer": "Надіслати в слайсер",
|
||||
"ui.sent_to_slicer": "Надіслано в {slicer}",
|
||||
"ui.sheet_gap": "Проміжок",
|
||||
"ui.shell": "Оболонка",
|
||||
"ui.show": "Показати",
|
||||
"ui.showAll": "Показати все",
|
||||
|
||||
+61
-1
@@ -31,6 +31,7 @@
|
||||
"Arc length": "弧长",
|
||||
"Area": "面积",
|
||||
"Attachment": "附着",
|
||||
"BOUNDARY SURFACE": "边界曲面",
|
||||
"BROWSER": "浏览器",
|
||||
"Bend Line Position": "弯折线位置",
|
||||
"Bend Position": "折弯位置",
|
||||
@@ -73,6 +74,7 @@
|
||||
"Counterbore depth": "沉孔深度",
|
||||
"Countersink angle": "沉头角度",
|
||||
"Create Issue — review draft": "创建 Issue——审阅草稿",
|
||||
"Cross curves": "交叉曲线",
|
||||
"Cube": "立方体",
|
||||
"Current orthographic view (1:1 mm)": "当前正交视图(1:1毫米)",
|
||||
"Current perspective view": "当前透视视图",
|
||||
@@ -86,6 +88,7 @@
|
||||
"Density": "密度",
|
||||
"Designs you open or save appear here.": "你打开或保存的设计会显示在此处。",
|
||||
"Determined by To Object. Choose Angle to edit the angular extent.": "由“到对象”确定。选择“角度”可编辑角度范围。",
|
||||
"Direction": "方向",
|
||||
"Discard": "放弃",
|
||||
"Do not export hidden edges": "不导出隐藏线",
|
||||
"Don't ask again": "不再询问",
|
||||
@@ -119,7 +122,6 @@
|
||||
"Follow the camera (Look At sketches)": "跟随相机(注视草图)",
|
||||
"Font": "字体",
|
||||
"Full Length": "全长",
|
||||
"Gap": "间隙",
|
||||
"Grid": "网格",
|
||||
"Height": "高度",
|
||||
"Hidden edges": "隐藏线",
|
||||
@@ -128,6 +130,7 @@
|
||||
"Home view": "主视图",
|
||||
"In This Design": "在此设计中",
|
||||
"In place (surfaces)": "原位(曲面)",
|
||||
"In plane": "在平面内",
|
||||
"Inside": "内侧",
|
||||
"Interferes": "发生干涉",
|
||||
"Issue title": "Issue 标题",
|
||||
@@ -163,6 +166,7 @@
|
||||
"No matching appearances": "没有匹配的外观",
|
||||
"No visible bodies to export.": "没有可导出的可见实体。",
|
||||
"None": "无",
|
||||
"Normal to sketch": "垂直于草图",
|
||||
"Not available yet": "暂不可用",
|
||||
"Notch": "缺口",
|
||||
"Opacity": "不透明度",
|
||||
@@ -555,6 +559,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": "拖动草图点",
|
||||
@@ -649,7 +655,12 @@
|
||||
"coupling.rack": "齿条",
|
||||
"coupling.radius_1": "半径 1",
|
||||
"coupling.radius_2": "半径 2",
|
||||
"dialog.bodies_count": "{count} 个实体",
|
||||
"dialog.clearance_invalid": "间隙必须是 0 或更大的长度",
|
||||
"dialog.compute": "计算",
|
||||
"dialog.compute_again": "重新计算",
|
||||
"dialog.computing": "正在计算… {seconds} 秒",
|
||||
"dialog.could_not_be_checked": "无法检查",
|
||||
"dialog.custom_axis": "↖ 自定义轴",
|
||||
"dialog.edit_title": "编辑 {title}",
|
||||
"dialog.select": "↖ 选择",
|
||||
@@ -719,9 +730,11 @@
|
||||
"new configuration": "新配置",
|
||||
"new name": "新名称",
|
||||
"new parameter": "新参数",
|
||||
"no hole": "无孔",
|
||||
"no interference": "无干涉",
|
||||
"no joint that moves yet": "尚无运动接头",
|
||||
"no picture": "无图片",
|
||||
"none": "无",
|
||||
"pattern.alignment_2": "对齐 2",
|
||||
"pattern.alignment_hint": "数量包含原始图形。居中时副本分布在两侧;数量为偶数时,反转可切换多一个副本的一侧。",
|
||||
"pattern.direction_1": "方向 1",
|
||||
@@ -794,6 +807,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": "右对齐",
|
||||
@@ -806,6 +821,7 @@
|
||||
"sketch_text.path_clear": "将文字移离其曲线",
|
||||
"sketch_text.path_hint": "文字所跟随的草图曲线:先单击此处,再选择曲线",
|
||||
"sketch_text.use_font_file": "使用字体文件 {path}",
|
||||
"solid": "实心",
|
||||
"the parts library is not available": "零件库不可用",
|
||||
"thread.automatic_size": "自动尺寸",
|
||||
"thread.designation": "螺纹标记",
|
||||
@@ -817,6 +833,7 @@
|
||||
"timeline.rename_group": "重命名特征文件夹",
|
||||
"timeline.reorder_many": "特征重新排序",
|
||||
"timeline.ungroup": "移除特征文件夹",
|
||||
"to the face": "到面",
|
||||
"to the next face": "到下一个面",
|
||||
"toolbar.image": "图像",
|
||||
"toolbar.render": "渲染",
|
||||
@@ -832,12 +849,15 @@
|
||||
"ui.activate": "激活",
|
||||
"ui.active_component": "活动组件",
|
||||
"ui.agent_chat": "智能体聊天",
|
||||
"ui.agent_chat_add": "附加文件",
|
||||
"ui.agent_chat_add_file": "选择文件…",
|
||||
"ui.agent_chat_allow": "允许",
|
||||
"ui.agent_chat_allowed": "已允许",
|
||||
"ui.agent_chat_answered": "回答:{option}",
|
||||
"ui.agent_chat_approval": "允许智能体执行此操作吗?",
|
||||
"ui.agent_chat_attach_view": "附加视图",
|
||||
"ui.agent_chat_attach_view_tip": "随消息发送当前视图的图片",
|
||||
"ui.agent_chat_attachable": "图片、文本和 CAD 文件",
|
||||
"ui.agent_chat_auth_agent": "使用 {name} 登录",
|
||||
"ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 SolidCraft。",
|
||||
"ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;SolidCraft 从不接触你的密码或密钥。",
|
||||
@@ -857,11 +877,33 @@
|
||||
"ui.agent_chat_deny": "拒绝",
|
||||
"ui.agent_chat_desktop_only": "智能体聊天需要桌面应用。",
|
||||
"ui.agent_chat_detecting": "正在查找智能体…",
|
||||
"ui.agent_chat_dock": "停靠到侧面板",
|
||||
"ui.agent_chat_dock_left": "停靠在左侧",
|
||||
"ui.agent_chat_dock_right": "停靠在右侧",
|
||||
"ui.agent_chat_drop_hint": "拖放到此处,附加到下一条消息",
|
||||
"ui.agent_chat_edit": "编辑",
|
||||
"ui.agent_chat_exited": "智能体已停止。",
|
||||
"ui.agent_chat_failed_start": "智能体无法启动:{error}",
|
||||
"ui.agent_chat_file_cad_not_read": "{name}:CAD 文件会导入设计,而不是读取",
|
||||
"ui.agent_chat_file_empty_picture": "{name}:空图片",
|
||||
"ui.agent_chat_file_error": "{name}:{error}",
|
||||
"ui.agent_chat_file_heic": "{name}:这里无法读取 HEIC 图片;请先导出为 JPEG 或 PNG",
|
||||
"ui.agent_chat_file_importing": "正在导入设计…",
|
||||
"ui.agent_chat_file_kinds": "{name}:可以附加图片(PNG、JPEG、GIF、WebP、BMP、TIFF)、文本文件(包括 SVG)和 CAD 文件(STEP、IGES、FreeCAD、Rhino、STL、3MF、OBJ、glTF)",
|
||||
"ui.agent_chat_file_loading": "正在读取文件…",
|
||||
"ui.agent_chat_file_not_a_file": "{name}:不是文件",
|
||||
"ui.agent_chat_file_not_a_picture": "{name}:不是图片",
|
||||
"ui.agent_chat_file_not_utf8": "{name}:不是 UTF-8 文本",
|
||||
"ui.agent_chat_file_picture_too_big": "{name}:图片最大 {max} MB",
|
||||
"ui.agent_chat_file_text_too_big": "{name}:文本文件最大 {max} KB",
|
||||
"ui.agent_chat_file_too_large": "{name}:太大",
|
||||
"ui.agent_chat_file_too_large_to_send": "{name}:即使缩小也太大,无法发送",
|
||||
"ui.agent_chat_file_unreadable_picture": "{name}:无法读取的图片",
|
||||
"ui.agent_chat_files": "文件:{files}",
|
||||
"ui.agent_chat_files_left_out": "一条消息最多发送 {max} 个文件;未发送:{files}",
|
||||
"ui.agent_chat_files_loading": "正在等待文件读取完成",
|
||||
"ui.agent_chat_files_max": "一条消息最多附加 {max} 个文件",
|
||||
"ui.agent_chat_import_failed": "{name}:导入失败:{error}",
|
||||
"ui.agent_chat_intro": "与你已在使用的编程智能体或你选择的模型端点聊天。它通过 SolidCraft 的工具处理打开的设计:更改即时显示,每条消息的更改可一步撤销。无需询问即可执行的范围在“首选项 > AI”中设置。",
|
||||
"ui.agent_chat_key_in_env": "已设置密钥",
|
||||
"ui.agent_chat_left": "左侧",
|
||||
@@ -874,14 +916,18 @@
|
||||
"ui.agent_chat_no_cli": "在应用旁边未找到 solidcraft-cli,因此智能体无法访问设计。",
|
||||
"ui.agent_chat_no_control": "智能体聊天的端点未能启动,因此智能体无法访问设计。",
|
||||
"ui.agent_chat_no_images": "该智能体不接收图片",
|
||||
"ui.agent_chat_no_picture": "剪贴板上没有图片",
|
||||
"ui.agent_chat_none_found": "未找到编程智能体。请安装以下之一,然后点击刷新:",
|
||||
"ui.agent_chat_none_found_short": "未找到智能体",
|
||||
"ui.agent_chat_not_installed": "未安装",
|
||||
"ui.agent_chat_not_signed_in": "未登录",
|
||||
"ui.agent_chat_own_window": "独立窗口",
|
||||
"ui.agent_chat_paste_picture": "粘贴图片",
|
||||
"ui.agent_chat_permission": "智能体请求运行:{title}",
|
||||
"ui.agent_chat_pick": "智能体",
|
||||
"ui.agent_chat_placeholder": "让智能体修改设计…",
|
||||
"ui.agent_chat_placeholder_busy": "输入消息,待智能体完成后发送(Ctrl+Enter:立即发送)",
|
||||
"ui.agent_chat_pop_out": "在独立窗口中打开(可移到另一个屏幕)",
|
||||
"ui.agent_chat_privacy": "除了你以自己的账户运行的智能体之外,不会向任何地方发送任何内容。",
|
||||
"ui.agent_chat_privacy_note": "SolidCraft 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。SolidCraft 从不查看或保存任何密钥或密码。",
|
||||
"ui.agent_chat_queue": "加入队列",
|
||||
@@ -891,6 +937,8 @@
|
||||
"ui.agent_chat_refresh": "刷新",
|
||||
"ui.agent_chat_refused": "不允许智能体执行:{reason}",
|
||||
"ui.agent_chat_remove": "从队列中移除",
|
||||
"ui.agent_chat_remove_file": "从这条消息中移除",
|
||||
"ui.agent_chat_remove_imported": "从这条消息中移除;零件仍保留在设计中(撤销可将其删除)",
|
||||
"ui.agent_chat_right": "右侧",
|
||||
"ui.agent_chat_save": "保存",
|
||||
"ui.agent_chat_send": "发送",
|
||||
@@ -1005,6 +1053,7 @@
|
||||
"ui.base_point": "基点",
|
||||
"ui.blocked": "被遮挡",
|
||||
"ui.bodies": "实体",
|
||||
"ui.bodies_all_if_none": "实体(未选择则全部)",
|
||||
"ui.box": "长方体",
|
||||
"ui.break": "打断",
|
||||
"ui.calibrate": "校准",
|
||||
@@ -1059,6 +1108,7 @@
|
||||
"ui.continuity_g0": "G0 折痕",
|
||||
"ui.continuity_g1": "G1 相切",
|
||||
"ui.continuity_g2": "G2 曲率",
|
||||
"ui.continuity_gap": "缝隙",
|
||||
"ui.copy": "复制",
|
||||
"ui.corner": "角",
|
||||
"ui.corners": "角点",
|
||||
@@ -1146,6 +1196,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": "加载项",
|
||||
@@ -1172,6 +1224,7 @@
|
||||
"ui.heading_home": "主页",
|
||||
"ui.heading_insert": "插入",
|
||||
"ui.heading_inspect": "检查",
|
||||
"ui.heading_interference": "干涉",
|
||||
"ui.heading_loft": "放样",
|
||||
"ui.heading_make": "制作",
|
||||
"ui.heading_measure": "测量",
|
||||
@@ -1296,6 +1349,7 @@
|
||||
"ui.one_side": "单侧",
|
||||
"ui.open": "打开",
|
||||
"ui.open_ellipsis": "打开...",
|
||||
"ui.open_ends_tip": "未接触任何曲线",
|
||||
"ui.open_preferences": "打开首选项",
|
||||
"ui.open_recent": "打开最近",
|
||||
"ui.operation": "操作",
|
||||
@@ -1357,6 +1411,8 @@
|
||||
"ui.reset_the_settings_on_this_page": "重置此页面上的设置",
|
||||
"ui.restore": "还原",
|
||||
"ui.restore_defaults": "恢复默认值",
|
||||
"ui.reverse_horizontal_pan_direction": "反转水平平移方向",
|
||||
"ui.reverse_horizontal_pan_tip": "水平双指滚动和拇指滚轮向相反方向平移",
|
||||
"ui.reverse_zoom_direction": "反转缩放方向",
|
||||
"ui.revolve": "旋转",
|
||||
"ui.right": "右视",
|
||||
@@ -1378,6 +1434,7 @@
|
||||
"ui.send": "发送",
|
||||
"ui.send_to_slicer": "发送到切片软件",
|
||||
"ui.sent_to_slicer": "已发送到 {slicer}",
|
||||
"ui.sheet_gap": "间隙",
|
||||
"ui.shell": "抽壳",
|
||||
"ui.show": "显示",
|
||||
"ui.showAll": "显示全部",
|
||||
@@ -1438,6 +1495,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 +1519,7 @@
|
||||
"ui.units_of_new_designs": "新设计的单位",
|
||||
"ui.unlock": "解锁",
|
||||
"ui.unsuppress": "取消抑制",
|
||||
"ui.untied_ends_tip": "仅位置重合",
|
||||
"ui.untitled": "无标题",
|
||||
"ui.up_axis_note": "视图中朝上的轴(轴视图、主视图、注视)。设计本身不变。",
|
||||
"ui.updateToCurrentView": "更新为当前视图",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"format": "solidcraft-theme/1",
|
||||
"name": "Gruvbox Dark Hard",
|
||||
"base": "dark",
|
||||
"colors": {
|
||||
"app_bar": "#141617",
|
||||
"toolbar": "#1d2021",
|
||||
"panel": "#1d2021",
|
||||
"panel_header": "#3c3836",
|
||||
"border": "#504945",
|
||||
"text": "#ebdbb2",
|
||||
"text_dim": "#a89984",
|
||||
"accent": "#83a598",
|
||||
"accent_soft": "#414f4b",
|
||||
"sketch_accent": "#83a598",
|
||||
"error": "#fb4934",
|
||||
"warning": "#fabd2f",
|
||||
"icon": "#d5c4a1",
|
||||
"icon_fill": "#458588",
|
||||
"timeline": "#141617",
|
||||
"hover": "#46403e",
|
||||
"sel_face": "#458588",
|
||||
"sel_edge": "#83a598",
|
||||
"sel_edge_rim": "#aec4bc",
|
||||
"sel_profile": "#076678",
|
||||
"sel_profile_edge": "#458588",
|
||||
"hover_profile": "#83a59830",
|
||||
"hover_profile_edge": "#83a598",
|
||||
"sel_vertex": "#83a598",
|
||||
"origin_plane": "#fe801950",
|
||||
"origin_plane_edge": "#d65d0ec8",
|
||||
"origin_plane_hover": "#83a59896",
|
||||
"origin_point": "#d5c4a1",
|
||||
"axis_x": "#fb4934",
|
||||
"axis_y": "#b8bb26",
|
||||
"axis_z": "#83a598",
|
||||
"construction_plane": "#fe801940",
|
||||
"construction_geom": "#fe8019",
|
||||
"body": "#a89984",
|
||||
"body_edge": "#141617",
|
||||
"manipulator": "#83a598",
|
||||
"preview_add": "#83a598",
|
||||
"preview_new": "#fabd2f",
|
||||
"preview_sketch": "#b8bb26",
|
||||
"preview_cut": "#fb49346e",
|
||||
"cube_face": "#ebdbb2",
|
||||
"cube_border": "#665c54",
|
||||
"cube_text": "#282828",
|
||||
"cube_hover": "#83a598",
|
||||
"viewport_top": "#2c2c2c",
|
||||
"viewport_bottom": "#2c2c2c",
|
||||
"grid": "#443f3c",
|
||||
"grid_major": "#504945",
|
||||
"body_selected": "#83a598",
|
||||
"sketch": "#83a598",
|
||||
"sketch_fixed": "#ebdbb2",
|
||||
"sketch_locked": "#b8bb26",
|
||||
"dimension": "#ebdbb2",
|
||||
"dimension_driven": "#928374",
|
||||
"sketch_point": "#ebdbb2",
|
||||
"sketch_point_free": "#83a598",
|
||||
"dialog_bg": "#3c3836",
|
||||
"dialog_border": "#504945",
|
||||
"chip": "#458588",
|
||||
"heading_bg": "#1d2021",
|
||||
"subheading_bg": "#2c2c2c",
|
||||
"section_cap": "#d65d0e",
|
||||
"profile_fill": "#83a59828",
|
||||
"glyph_bg": "#2c2c2c",
|
||||
"glyph_edge": "#d65d0e",
|
||||
"glyph_text": "#fe8019",
|
||||
"overlay": "#1d2021f0",
|
||||
"rubber_band": "#83a598",
|
||||
"app_bar_text": "#ebdbb2",
|
||||
"app_bar_hover": "#ebdbb21c",
|
||||
"timeline_item": "#3c3836",
|
||||
"timeline_item_error": "#63221f",
|
||||
"timeline_marker": "#bdae93",
|
||||
"field": "#141617",
|
||||
"box_window": "#83a598",
|
||||
"box_crossing": "#b8bb26",
|
||||
"sketch_projected": "#d3869b",
|
||||
"sketch_lost": "#fb4934",
|
||||
"sketch_construction": "#fe8019",
|
||||
"sketch_centerline": "#d65d0e",
|
||||
"bend_up": "#b8bb26",
|
||||
"bend_down": "#fe8019",
|
||||
"interference": "#fb4934",
|
||||
"triad": [
|
||||
"#fb4934",
|
||||
"#b8bb26",
|
||||
"#83a598"
|
||||
],
|
||||
"gizmo": [
|
||||
"#fb4934",
|
||||
"#b8bb26",
|
||||
"#83a598"
|
||||
],
|
||||
"app_bar_icon": [
|
||||
"#ebdbb2",
|
||||
"#928374",
|
||||
"#83a598"
|
||||
],
|
||||
"icons.folder": "#d79921",
|
||||
"icons.axes": [
|
||||
"#fb4934",
|
||||
"#b8bb26",
|
||||
"#83a598"
|
||||
],
|
||||
"icons.agent": "#d3869b",
|
||||
"icons.warning": "#fabd2f",
|
||||
"icons.interference": "#fb4934",
|
||||
"icons.paint": [
|
||||
"#fb4934",
|
||||
"#83a598",
|
||||
"#fabd2f"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"format": "solidcraft-theme/1",
|
||||
"name": "Gruvbox Dark Soft",
|
||||
"base": "dark",
|
||||
"colors": {
|
||||
"app_bar": "#1d2021",
|
||||
"toolbar": "#32302f",
|
||||
"panel": "#32302f",
|
||||
"panel_header": "#3c3836",
|
||||
"border": "#504945",
|
||||
"text": "#ebdbb2",
|
||||
"text_dim": "#a89984",
|
||||
"accent": "#83a598",
|
||||
"accent_soft": "#4e5954",
|
||||
"sketch_accent": "#83a598",
|
||||
"error": "#fb4934",
|
||||
"warning": "#fabd2f",
|
||||
"icon": "#d5c4a1",
|
||||
"icon_fill": "#458588",
|
||||
"timeline": "#1d2021",
|
||||
"hover": "#46403e",
|
||||
"sel_face": "#458588",
|
||||
"sel_edge": "#83a598",
|
||||
"sel_edge_rim": "#aec4bc",
|
||||
"sel_profile": "#076678",
|
||||
"sel_profile_edge": "#458588",
|
||||
"hover_profile": "#83a59830",
|
||||
"hover_profile_edge": "#83a598",
|
||||
"sel_vertex": "#83a598",
|
||||
"origin_plane": "#fe801950",
|
||||
"origin_plane_edge": "#d65d0ec8",
|
||||
"origin_plane_hover": "#83a59896",
|
||||
"origin_point": "#d5c4a1",
|
||||
"axis_x": "#fb4934",
|
||||
"axis_y": "#b8bb26",
|
||||
"axis_z": "#83a598",
|
||||
"construction_plane": "#fe801940",
|
||||
"construction_geom": "#fe8019",
|
||||
"body": "#a89984",
|
||||
"body_edge": "#1d2021",
|
||||
"manipulator": "#83a598",
|
||||
"preview_add": "#83a598",
|
||||
"preview_new": "#fabd2f",
|
||||
"preview_sketch": "#b8bb26",
|
||||
"preview_cut": "#fb49346e",
|
||||
"cube_face": "#ebdbb2",
|
||||
"cube_border": "#665c54",
|
||||
"cube_text": "#282828",
|
||||
"cube_hover": "#83a598",
|
||||
"viewport_top": "#373432",
|
||||
"viewport_bottom": "#373432",
|
||||
"grid": "#443f3c",
|
||||
"grid_major": "#504945",
|
||||
"body_selected": "#83a598",
|
||||
"sketch": "#83a598",
|
||||
"sketch_fixed": "#ebdbb2",
|
||||
"sketch_locked": "#b8bb26",
|
||||
"dimension": "#ebdbb2",
|
||||
"dimension_driven": "#928374",
|
||||
"sketch_point": "#ebdbb2",
|
||||
"sketch_point_free": "#83a598",
|
||||
"dialog_bg": "#3c3836",
|
||||
"dialog_border": "#504945",
|
||||
"chip": "#458588",
|
||||
"heading_bg": "#32302f",
|
||||
"subheading_bg": "#373432",
|
||||
"section_cap": "#d65d0e",
|
||||
"profile_fill": "#83a59828",
|
||||
"glyph_bg": "#373432",
|
||||
"glyph_edge": "#d65d0e",
|
||||
"glyph_text": "#fe8019",
|
||||
"overlay": "#32302ff0",
|
||||
"rubber_band": "#83a598",
|
||||
"app_bar_text": "#ebdbb2",
|
||||
"app_bar_hover": "#ebdbb21c",
|
||||
"timeline_item": "#3c3836",
|
||||
"timeline_item_error": "#702b28",
|
||||
"timeline_marker": "#bdae93",
|
||||
"field": "#1d2021",
|
||||
"box_window": "#83a598",
|
||||
"box_crossing": "#b8bb26",
|
||||
"sketch_projected": "#d3869b",
|
||||
"sketch_lost": "#fb4934",
|
||||
"sketch_construction": "#fe8019",
|
||||
"sketch_centerline": "#d65d0e",
|
||||
"bend_up": "#b8bb26",
|
||||
"bend_down": "#fe8019",
|
||||
"interference": "#fb4934",
|
||||
"triad": [
|
||||
"#fb4934",
|
||||
"#b8bb26",
|
||||
"#83a598"
|
||||
],
|
||||
"gizmo": [
|
||||
"#fb4934",
|
||||
"#b8bb26",
|
||||
"#83a598"
|
||||
],
|
||||
"app_bar_icon": [
|
||||
"#ebdbb2",
|
||||
"#928374",
|
||||
"#83a598"
|
||||
],
|
||||
"icons.folder": "#d79921",
|
||||
"icons.axes": [
|
||||
"#fb4934",
|
||||
"#b8bb26",
|
||||
"#83a598"
|
||||
],
|
||||
"icons.agent": "#d3869b",
|
||||
"icons.warning": "#fabd2f",
|
||||
"icons.interference": "#fb4934",
|
||||
"icons.paint": [
|
||||
"#fb4934",
|
||||
"#83a598",
|
||||
"#fabd2f"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"format": "solidcraft-theme/1",
|
||||
"name": "Gruvbox Dark",
|
||||
"base": "dark",
|
||||
"colors": {
|
||||
"app_bar": "#1d2021",
|
||||
"toolbar": "#282828",
|
||||
"panel": "#282828",
|
||||
"panel_header": "#3c3836",
|
||||
"border": "#504945",
|
||||
"text": "#ebdbb2",
|
||||
"text_dim": "#a89984",
|
||||
"accent": "#83a598",
|
||||
"accent_soft": "#48544f",
|
||||
"sketch_accent": "#83a598",
|
||||
"error": "#fb4934",
|
||||
"warning": "#fabd2f",
|
||||
"icon": "#d5c4a1",
|
||||
"icon_fill": "#458588",
|
||||
"timeline": "#1d2021",
|
||||
"hover": "#46403e",
|
||||
"sel_face": "#458588",
|
||||
"sel_edge": "#83a598",
|
||||
"sel_edge_rim": "#aec4bc",
|
||||
"sel_profile": "#076678",
|
||||
"sel_profile_edge": "#458588",
|
||||
"hover_profile": "#83a59830",
|
||||
"hover_profile_edge": "#83a598",
|
||||
"sel_vertex": "#83a598",
|
||||
"origin_plane": "#fe801950",
|
||||
"origin_plane_edge": "#d65d0ec8",
|
||||
"origin_plane_hover": "#83a59896",
|
||||
"origin_point": "#d5c4a1",
|
||||
"axis_x": "#fb4934",
|
||||
"axis_y": "#b8bb26",
|
||||
"axis_z": "#83a598",
|
||||
"construction_plane": "#fe801940",
|
||||
"construction_geom": "#fe8019",
|
||||
"body": "#a89984",
|
||||
"body_edge": "#1d2021",
|
||||
"manipulator": "#83a598",
|
||||
"preview_add": "#83a598",
|
||||
"preview_new": "#fabd2f",
|
||||
"preview_sketch": "#b8bb26",
|
||||
"preview_cut": "#fb49346e",
|
||||
"cube_face": "#ebdbb2",
|
||||
"cube_border": "#665c54",
|
||||
"cube_text": "#282828",
|
||||
"cube_hover": "#83a598",
|
||||
"viewport_top": "#32302f",
|
||||
"viewport_bottom": "#32302f",
|
||||
"grid": "#443f3c",
|
||||
"grid_major": "#504945",
|
||||
"body_selected": "#83a598",
|
||||
"sketch": "#83a598",
|
||||
"sketch_fixed": "#ebdbb2",
|
||||
"sketch_locked": "#b8bb26",
|
||||
"dimension": "#ebdbb2",
|
||||
"dimension_driven": "#928374",
|
||||
"sketch_point": "#ebdbb2",
|
||||
"sketch_point_free": "#83a598",
|
||||
"dialog_bg": "#3c3836",
|
||||
"dialog_border": "#504945",
|
||||
"chip": "#458588",
|
||||
"heading_bg": "#282828",
|
||||
"subheading_bg": "#32302f",
|
||||
"section_cap": "#d65d0e",
|
||||
"profile_fill": "#83a59828",
|
||||
"glyph_bg": "#32302f",
|
||||
"glyph_edge": "#d65d0e",
|
||||
"glyph_text": "#fe8019",
|
||||
"overlay": "#282828f0",
|
||||
"rubber_band": "#83a598",
|
||||
"app_bar_text": "#ebdbb2",
|
||||
"app_bar_hover": "#ebdbb21c",
|
||||
"timeline_item": "#3c3836",
|
||||
"timeline_item_error": "#6a2624",
|
||||
"timeline_marker": "#bdae93",
|
||||
"field": "#1d2021",
|
||||
"box_window": "#83a598",
|
||||
"box_crossing": "#b8bb26",
|
||||
"sketch_projected": "#d3869b",
|
||||
"sketch_lost": "#fb4934",
|
||||
"sketch_construction": "#fe8019",
|
||||
"sketch_centerline": "#d65d0e",
|
||||
"bend_up": "#b8bb26",
|
||||
"bend_down": "#fe8019",
|
||||
"interference": "#fb4934",
|
||||
"triad": [
|
||||
"#fb4934",
|
||||
"#b8bb26",
|
||||
"#83a598"
|
||||
],
|
||||
"gizmo": [
|
||||
"#fb4934",
|
||||
"#b8bb26",
|
||||
"#83a598"
|
||||
],
|
||||
"app_bar_icon": [
|
||||
"#ebdbb2",
|
||||
"#928374",
|
||||
"#83a598"
|
||||
],
|
||||
"icons.folder": "#d79921",
|
||||
"icons.axes": [
|
||||
"#fb4934",
|
||||
"#b8bb26",
|
||||
"#83a598"
|
||||
],
|
||||
"icons.agent": "#d3869b",
|
||||
"icons.warning": "#fabd2f",
|
||||
"icons.interference": "#fb4934",
|
||||
"icons.paint": [
|
||||
"#fb4934",
|
||||
"#83a598",
|
||||
"#fabd2f"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"format": "solidcraft-theme/1",
|
||||
"name": "Gruvbox Light Hard",
|
||||
"base": "light",
|
||||
"colors": {
|
||||
"app_bar": "#3c3836",
|
||||
"toolbar": "#f9f5d7",
|
||||
"panel": "#f9f5d7",
|
||||
"panel_header": "#ebdbb2",
|
||||
"border": "#d5c4a1",
|
||||
"text": "#3c3836",
|
||||
"text_dim": "#7c6f64",
|
||||
"accent": "#076678",
|
||||
"accent_soft": "#c9d8c4",
|
||||
"sketch_accent": "#076678",
|
||||
"error": "#9d0006",
|
||||
"warning": "#b57614",
|
||||
"icon": "#504945",
|
||||
"icon_fill": "#bdae93",
|
||||
"timeline": "#ebdbb2",
|
||||
"hover": "#f1e5c1",
|
||||
"sel_face": "#458588",
|
||||
"sel_edge": "#076678",
|
||||
"sel_edge_rim": "#83a598",
|
||||
"sel_profile": "#076678",
|
||||
"sel_profile_edge": "#458588",
|
||||
"hover_profile": "#45858830",
|
||||
"hover_profile_edge": "#458588",
|
||||
"sel_vertex": "#076678",
|
||||
"origin_plane": "#fe80195e",
|
||||
"origin_plane_edge": "#d65d0ec8",
|
||||
"origin_plane_hover": "#45858896",
|
||||
"origin_point": "#d5c4a1",
|
||||
"axis_x": "#cc241d",
|
||||
"axis_y": "#98971a",
|
||||
"axis_z": "#458588",
|
||||
"construction_plane": "#fe801946",
|
||||
"construction_geom": "#af3a03",
|
||||
"body": "#bdae93",
|
||||
"body_edge": "#3c3836",
|
||||
"manipulator": "#076678",
|
||||
"preview_add": "#458588",
|
||||
"preview_new": "#d79921",
|
||||
"preview_sketch": "#98971a",
|
||||
"preview_cut": "#cc241d6e",
|
||||
"cube_face": "#fbf1c7",
|
||||
"cube_border": "#7c6f64",
|
||||
"cube_text": "#3c3836",
|
||||
"cube_hover": "#83a598",
|
||||
"viewport_top": "#fbf8e3",
|
||||
"viewport_bottom": "#ebdbb2",
|
||||
"grid": "#d5c4a1",
|
||||
"grid_major": "#bdae93",
|
||||
"body_selected": "#83a598",
|
||||
"sketch": "#076678",
|
||||
"sketch_fixed": "#282828",
|
||||
"sketch_locked": "#79740e",
|
||||
"dimension": "#3c3836",
|
||||
"dimension_driven": "#7c6f64",
|
||||
"sketch_point": "#282828",
|
||||
"sketch_point_free": "#076678",
|
||||
"dialog_bg": "#f9f5d7",
|
||||
"dialog_border": "#d5c4a1",
|
||||
"chip": "#458588",
|
||||
"heading_bg": "#d5c4a1",
|
||||
"subheading_bg": "#ebdbb2",
|
||||
"section_cap": "#d65d0e",
|
||||
"profile_fill": "#fabd2f46",
|
||||
"glyph_bg": "#fcfaeb",
|
||||
"glyph_edge": "#af3a03",
|
||||
"glyph_text": "#af3a03",
|
||||
"overlay": "#f9f5d7eb",
|
||||
"rubber_band": "#076678",
|
||||
"app_bar_text": "#fbf1c7",
|
||||
"app_bar_hover": "#fbf1c71c",
|
||||
"timeline_item": "#f9f5d7",
|
||||
"timeline_item_error": "#f0cbb2",
|
||||
"timeline_marker": "#504945",
|
||||
"field": "#fcfaeb",
|
||||
"box_window": "#076678",
|
||||
"box_crossing": "#79740e",
|
||||
"sketch_projected": "#8f3f71",
|
||||
"sketch_lost": "#9d0006",
|
||||
"sketch_construction": "#af3a03",
|
||||
"sketch_centerline": "#d65d0e",
|
||||
"bend_up": "#79740e",
|
||||
"bend_down": "#af3a03",
|
||||
"interference": "#cc241d",
|
||||
"triad": [
|
||||
"#cc241d",
|
||||
"#98971a",
|
||||
"#458588"
|
||||
],
|
||||
"gizmo": [
|
||||
"#cc241d",
|
||||
"#98971a",
|
||||
"#458588"
|
||||
],
|
||||
"app_bar_icon": [
|
||||
"#fbf1c7",
|
||||
"#a89984",
|
||||
"#83a598"
|
||||
],
|
||||
"icons.folder": "#d79921",
|
||||
"icons.axes": [
|
||||
"#cc241d",
|
||||
"#98971a",
|
||||
"#458588"
|
||||
],
|
||||
"icons.agent": "#8f3f71",
|
||||
"icons.warning": "#d79921",
|
||||
"icons.interference": "#cc241d",
|
||||
"icons.paint": [
|
||||
"#cc241d",
|
||||
"#458588",
|
||||
"#d79921"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"format": "solidcraft-theme/1",
|
||||
"name": "Gruvbox Light Soft",
|
||||
"base": "light",
|
||||
"colors": {
|
||||
"app_bar": "#3c3836",
|
||||
"toolbar": "#f2e5bc",
|
||||
"panel": "#f2e5bc",
|
||||
"panel_header": "#ebdbb2",
|
||||
"border": "#d5c4a1",
|
||||
"text": "#3c3836",
|
||||
"text_dim": "#7c6f64",
|
||||
"accent": "#076678",
|
||||
"accent_soft": "#c3ccae",
|
||||
"sketch_accent": "#076678",
|
||||
"error": "#9d0006",
|
||||
"warning": "#b57614",
|
||||
"icon": "#504945",
|
||||
"icon_fill": "#bdae93",
|
||||
"timeline": "#ebdbb2",
|
||||
"hover": "#eedfb6",
|
||||
"sel_face": "#458588",
|
||||
"sel_edge": "#076678",
|
||||
"sel_edge_rim": "#83a598",
|
||||
"sel_profile": "#076678",
|
||||
"sel_profile_edge": "#458588",
|
||||
"hover_profile": "#45858830",
|
||||
"hover_profile_edge": "#458588",
|
||||
"sel_vertex": "#076678",
|
||||
"origin_plane": "#fe80195e",
|
||||
"origin_plane_edge": "#d65d0ec8",
|
||||
"origin_plane_hover": "#45858896",
|
||||
"origin_point": "#d5c4a1",
|
||||
"axis_x": "#cc241d",
|
||||
"axis_y": "#98971a",
|
||||
"axis_z": "#458588",
|
||||
"construction_plane": "#fe801946",
|
||||
"construction_geom": "#af3a03",
|
||||
"body": "#bdae93",
|
||||
"body_edge": "#3c3836",
|
||||
"manipulator": "#076678",
|
||||
"preview_add": "#458588",
|
||||
"preview_new": "#d79921",
|
||||
"preview_sketch": "#98971a",
|
||||
"preview_cut": "#cc241d6e",
|
||||
"cube_face": "#fbf1c7",
|
||||
"cube_border": "#7c6f64",
|
||||
"cube_text": "#3c3836",
|
||||
"cube_hover": "#83a598",
|
||||
"viewport_top": "#f6edd0",
|
||||
"viewport_bottom": "#ebdbb2",
|
||||
"grid": "#d5c4a1",
|
||||
"grid_major": "#bdae93",
|
||||
"body_selected": "#83a598",
|
||||
"sketch": "#076678",
|
||||
"sketch_fixed": "#282828",
|
||||
"sketch_locked": "#79740e",
|
||||
"dimension": "#3c3836",
|
||||
"dimension_driven": "#7c6f64",
|
||||
"sketch_point": "#282828",
|
||||
"sketch_point_free": "#076678",
|
||||
"dialog_bg": "#f2e5bc",
|
||||
"dialog_border": "#d5c4a1",
|
||||
"chip": "#458588",
|
||||
"heading_bg": "#d5c4a1",
|
||||
"subheading_bg": "#ebdbb2",
|
||||
"section_cap": "#d65d0e",
|
||||
"profile_fill": "#fabd2f46",
|
||||
"glyph_bg": "#f8f2de",
|
||||
"glyph_edge": "#af3a03",
|
||||
"glyph_text": "#af3a03",
|
||||
"overlay": "#f2e5bceb",
|
||||
"rubber_band": "#076678",
|
||||
"app_bar_text": "#fbf1c7",
|
||||
"app_bar_hover": "#fbf1c71c",
|
||||
"timeline_item": "#f2e5bc",
|
||||
"timeline_item_error": "#eabe9c",
|
||||
"timeline_marker": "#504945",
|
||||
"field": "#f8f2de",
|
||||
"box_window": "#076678",
|
||||
"box_crossing": "#79740e",
|
||||
"sketch_projected": "#8f3f71",
|
||||
"sketch_lost": "#9d0006",
|
||||
"sketch_construction": "#af3a03",
|
||||
"sketch_centerline": "#d65d0e",
|
||||
"bend_up": "#79740e",
|
||||
"bend_down": "#af3a03",
|
||||
"interference": "#cc241d",
|
||||
"triad": [
|
||||
"#cc241d",
|
||||
"#98971a",
|
||||
"#458588"
|
||||
],
|
||||
"gizmo": [
|
||||
"#cc241d",
|
||||
"#98971a",
|
||||
"#458588"
|
||||
],
|
||||
"app_bar_icon": [
|
||||
"#fbf1c7",
|
||||
"#a89984",
|
||||
"#83a598"
|
||||
],
|
||||
"icons.folder": "#d79921",
|
||||
"icons.axes": [
|
||||
"#cc241d",
|
||||
"#98971a",
|
||||
"#458588"
|
||||
],
|
||||
"icons.agent": "#8f3f71",
|
||||
"icons.warning": "#d79921",
|
||||
"icons.interference": "#cc241d",
|
||||
"icons.paint": [
|
||||
"#cc241d",
|
||||
"#458588",
|
||||
"#d79921"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"format": "solidcraft-theme/1",
|
||||
"name": "Gruvbox Light",
|
||||
"base": "light",
|
||||
"colors": {
|
||||
"app_bar": "#3c3836",
|
||||
"toolbar": "#fbf1c7",
|
||||
"panel": "#fbf1c7",
|
||||
"panel_header": "#ebdbb2",
|
||||
"border": "#d5c4a1",
|
||||
"text": "#3c3836",
|
||||
"text_dim": "#7c6f64",
|
||||
"accent": "#076678",
|
||||
"accent_soft": "#cad5b7",
|
||||
"sketch_accent": "#076678",
|
||||
"error": "#9d0006",
|
||||
"warning": "#b57614",
|
||||
"icon": "#504945",
|
||||
"icon_fill": "#bdae93",
|
||||
"timeline": "#ebdbb2",
|
||||
"hover": "#f1e4ba",
|
||||
"sel_face": "#458588",
|
||||
"sel_edge": "#076678",
|
||||
"sel_edge_rim": "#83a598",
|
||||
"sel_profile": "#076678",
|
||||
"sel_profile_edge": "#458588",
|
||||
"hover_profile": "#45858830",
|
||||
"hover_profile_edge": "#458588",
|
||||
"sel_vertex": "#076678",
|
||||
"origin_plane": "#fe80195e",
|
||||
"origin_plane_edge": "#d65d0ec8",
|
||||
"origin_plane_hover": "#45858896",
|
||||
"origin_point": "#d5c4a1",
|
||||
"axis_x": "#cc241d",
|
||||
"axis_y": "#98971a",
|
||||
"axis_z": "#458588",
|
||||
"construction_plane": "#fe801946",
|
||||
"construction_geom": "#af3a03",
|
||||
"body": "#bdae93",
|
||||
"body_edge": "#3c3836",
|
||||
"manipulator": "#076678",
|
||||
"preview_add": "#458588",
|
||||
"preview_new": "#d79921",
|
||||
"preview_sketch": "#98971a",
|
||||
"preview_cut": "#cc241d6e",
|
||||
"cube_face": "#fbf1c7",
|
||||
"cube_border": "#7c6f64",
|
||||
"cube_text": "#3c3836",
|
||||
"cube_hover": "#83a598",
|
||||
"viewport_top": "#fcf5d8",
|
||||
"viewport_bottom": "#ebdbb2",
|
||||
"grid": "#d5c4a1",
|
||||
"grid_major": "#bdae93",
|
||||
"body_selected": "#83a598",
|
||||
"sketch": "#076678",
|
||||
"sketch_fixed": "#282828",
|
||||
"sketch_locked": "#79740e",
|
||||
"dimension": "#3c3836",
|
||||
"dimension_driven": "#7c6f64",
|
||||
"sketch_point": "#282828",
|
||||
"sketch_point_free": "#076678",
|
||||
"dialog_bg": "#fbf1c7",
|
||||
"dialog_border": "#d5c4a1",
|
||||
"chip": "#458588",
|
||||
"heading_bg": "#d5c4a1",
|
||||
"subheading_bg": "#ebdbb2",
|
||||
"section_cap": "#d65d0e",
|
||||
"profile_fill": "#fabd2f46",
|
||||
"glyph_bg": "#fdf8e3",
|
||||
"glyph_edge": "#af3a03",
|
||||
"glyph_text": "#af3a03",
|
||||
"overlay": "#fbf1c7eb",
|
||||
"rubber_band": "#076678",
|
||||
"app_bar_text": "#fbf1c7",
|
||||
"app_bar_hover": "#fbf1c71c",
|
||||
"timeline_item": "#fbf1c7",
|
||||
"timeline_item_error": "#f2c8a5",
|
||||
"timeline_marker": "#504945",
|
||||
"field": "#fdf8e3",
|
||||
"box_window": "#076678",
|
||||
"box_crossing": "#79740e",
|
||||
"sketch_projected": "#8f3f71",
|
||||
"sketch_lost": "#9d0006",
|
||||
"sketch_construction": "#af3a03",
|
||||
"sketch_centerline": "#d65d0e",
|
||||
"bend_up": "#79740e",
|
||||
"bend_down": "#af3a03",
|
||||
"interference": "#cc241d",
|
||||
"triad": [
|
||||
"#cc241d",
|
||||
"#98971a",
|
||||
"#458588"
|
||||
],
|
||||
"gizmo": [
|
||||
"#cc241d",
|
||||
"#98971a",
|
||||
"#458588"
|
||||
],
|
||||
"app_bar_icon": [
|
||||
"#fbf1c7",
|
||||
"#a89984",
|
||||
"#83a598"
|
||||
],
|
||||
"icons.folder": "#d79921",
|
||||
"icons.axes": [
|
||||
"#cc241d",
|
||||
"#98971a",
|
||||
"#458588"
|
||||
],
|
||||
"icons.agent": "#8f3f71",
|
||||
"icons.warning": "#d79921",
|
||||
"icons.interference": "#cc241d",
|
||||
"icons.paint": [
|
||||
"#cc241d",
|
||||
"#458588",
|
||||
"#d79921"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -253,9 +253,20 @@ impl Document {
|
||||
return self.certified_pieces(body, indices, target.origin_feature);
|
||||
}
|
||||
if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
|
||||
matches!(source, Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index)
|
||||
}).collect();
|
||||
let side = |index: usize, exact: bool| matches!(body.body.face_sources().and_then(|sources| sources.get(index)), Some(Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. })) if *producer == target.origin_feature && current_sketch == sketch && (!exact || current_curve == curve));
|
||||
let count = body.body.face_sources().map_or(0, |sources| sources.len());
|
||||
let mut indices: Vec<_> = (0..count).filter(|index| side(*index, true)).collect();
|
||||
// A side drawn in collinear pieces is one face over all of them (#1788): a certificate
|
||||
// made when the pieces were faces of their own finds it by the piece's name, which
|
||||
// the joined face answers to.
|
||||
if indices.is_empty() {
|
||||
let names = crate::naming::names(body);
|
||||
indices = (0..count)
|
||||
.filter(|index| {
|
||||
side(*index, false) && crate::history_names::matches(&names, *index, &target.name) == Some(crate::history_names::Match::Alias)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! The existing length display policy, shared by windowed and headless consumers.
|
||||
//!
|
||||
//! Values are stored in millimetres and displayed in the five design units. Unknown unit
|
||||
//! names retain the millimetre fallback; expression units have a separate, broader vocabulary.
|
||||
//! These functions format values, not validate them: non-finite inputs retain Rust's `NaN`,
|
||||
//! `inf` and `-inf` text. Callers that require finite measurements must validate them first.
|
||||
|
||||
/// Units per millimetre and the unit's name. Unknown names use millimetres.
|
||||
pub fn unit_scale(units: &str) -> (f64, &'static str) {
|
||||
match units {
|
||||
"cm" => (0.1, "cm"),
|
||||
"m" => (0.001, "m"),
|
||||
"in" => (1.0 / 25.4, "in"),
|
||||
"ft" => (1.0 / 304.8, "ft"),
|
||||
_ => (1.0, "mm"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inch labels retain a meaningful fourth digit without promoting floating-point noise.
|
||||
/// `value` is already converted to the requested display unit, not a millimetre value.
|
||||
pub fn length_precision(value: f64, units: &str, decimals: u8) -> usize {
|
||||
let base = usize::from(decimals.min(8));
|
||||
if units != "in" {
|
||||
return base;
|
||||
}
|
||||
let needs_four = ((value * 10_000.0).round() - (value * 1000.0).round() * 10.0).abs() >= 0.5;
|
||||
base.max(if needs_four { 4 } else { 3 })
|
||||
}
|
||||
|
||||
/// A millimetre length in the requested display unit, without a unit suffix.
|
||||
pub fn length_number(mm: f64, units: &str, decimals: u8) -> String {
|
||||
let value = mm * unit_scale(units).0;
|
||||
format!("{:.*}", length_precision(value, units, decimals), value)
|
||||
}
|
||||
|
||||
/// A length in millimetres in `units`, with `decimals` (for display).
|
||||
pub fn format_length(mm: f64, units: &str, decimals: u8) -> String {
|
||||
let (_, u) = unit_scale(units);
|
||||
format!("{} {u}", length_number(mm, units, decimals))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_length, length_number, length_precision, unit_scale};
|
||||
|
||||
#[test]
|
||||
fn all_design_units_keep_bare_and_suffixed_labels() {
|
||||
for (mm, units, bare, suffixed) in [
|
||||
(12.5, "mm", "12.50", "12.50 mm"),
|
||||
(125.0, "cm", "12.50", "12.50 cm"),
|
||||
(12_500.0, "m", "12.50", "12.50 m"),
|
||||
(317.5, "in", "12.500", "12.500 in"),
|
||||
(3810.0, "ft", "12.50", "12.50 ft"),
|
||||
] {
|
||||
assert_eq!(length_number(mm, units, 2), bare, "{units}");
|
||||
assert_eq!(format_length(mm, units, 2), suffixed, "{units}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inch_labels_keep_three_or_four_digits_without_promoting_noise() {
|
||||
for (mm, decimals, expected) in [
|
||||
(25.4, 0, "1.000"),
|
||||
(25.4 * 1.234, 2, "1.234"),
|
||||
(25.4 * 1.2345, 0, "1.2345"),
|
||||
(-25.4 * 1.2345, 3, "-1.2345"),
|
||||
(25.4 * 0.001, 2, "0.001"),
|
||||
(25.4 * (1.0 + 1e-12), 2, "1.000"),
|
||||
(25.4, 4, "1.0000"),
|
||||
(25.4, 5, "1.00000"),
|
||||
(0.0, 0, "0.000"),
|
||||
] {
|
||||
assert_eq!(length_number(mm, "in", decimals), expected, "{mm} mm, {decimals} places");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_uses_the_converted_value_and_caps_requested_places() {
|
||||
assert_eq!(length_precision(1.2345, "in", 2), 4);
|
||||
assert_eq!(length_precision(1.234, "in", 2), 3);
|
||||
assert_eq!(length_precision(1.2345, "mm", 2), 2);
|
||||
assert_eq!(length_precision(1.2345, "in", 6), 6);
|
||||
assert_eq!(length_precision(1.2345, "in", u8::MAX), 8);
|
||||
assert_eq!(length_precision(1.2345, "mm", u8::MAX), 8);
|
||||
assert_eq!(length_number(12.5, "mm", u8::MAX), "12.50000000");
|
||||
assert_eq!(length_number(25.4, "in", u8::MAX), "1.00000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_and_signed_zero_keep_the_existing_display_policy() {
|
||||
for (mm, decimals, expected) in [
|
||||
(0.0, 2, "0.00"),
|
||||
(-0.0, 2, "-0.00"),
|
||||
(-0.0001, 2, "-0.00"),
|
||||
(-12.5, 2, "-12.50"),
|
||||
(12.5, 0, "12"),
|
||||
(13.5, 0, "14"),
|
||||
(-12.5, 0, "-12"),
|
||||
(-13.5, 0, "-14"),
|
||||
(1.25, 1, "1.2"),
|
||||
(1.75, 1, "1.8"),
|
||||
] {
|
||||
assert_eq!(length_number(mm, "mm", decimals), expected, "{mm} mm, {decimals} places");
|
||||
}
|
||||
assert_eq!(format_length(-0.0, "in", 2), "-0.000 in");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_display_units_keep_the_millimetre_fallback() {
|
||||
for units in ["", "inch", " in ", "IN", "unsupported"] {
|
||||
assert_eq!(unit_scale(units), (1.0, "mm"), "{units}");
|
||||
assert_eq!(length_number(25.4, units, 2), "25.40", "{units}");
|
||||
assert_eq!(format_length(25.4, units, 2), "25.40 mm", "{units}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_text_is_preserved_without_claiming_validation() {
|
||||
assert_eq!(length_number(f64::NAN, "mm", 2), "NaN");
|
||||
assert_eq!(format_length(f64::INFINITY, "in", u8::MAX), "inf in");
|
||||
assert_eq!(format_length(f64::NEG_INFINITY, "cm", 2), "-inf cm");
|
||||
}
|
||||
}
|
||||
@@ -901,7 +901,8 @@ pub enum FeatureKind {
|
||||
targets: Vec<BodyName>,
|
||||
},
|
||||
/// A thin wall from open sketch curves: `thickness` across the sketch plane, filling from
|
||||
/// the curves toward the body (to the next face, or `depth`). A web makes one per curve.
|
||||
/// the curves toward the body (to the next face, or `depth`), or with `normal` along the
|
||||
/// sketch normal. A web makes one per curve.
|
||||
Rib {
|
||||
sketch: SketchId,
|
||||
curves: Vec<String>,
|
||||
@@ -917,6 +918,10 @@ pub enum FeatureKind {
|
||||
/// Web: each curve is its own wall.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
web: bool,
|
||||
/// Grow the wall along the sketch normal (thickness across the curves, in the sketch
|
||||
/// plane) instead of across the plane from the curves.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
normal: bool,
|
||||
},
|
||||
/// Move planar faces (at the given points) onto a target plane parallel to them.
|
||||
ReplaceFace {
|
||||
@@ -1460,6 +1465,19 @@ pub enum FeatureKind {
|
||||
#[serde(default = "boundary_samples")]
|
||||
samples: usize,
|
||||
},
|
||||
/// One surface through a curve network (`surface.boundary` with `u` and `v`, issue #763): the
|
||||
/// curves `u` run one way (two or more: the surface lofts through them) and `v` cross them the
|
||||
/// other way (two each way: a Coons patch; more: a Gordon surface through every curve). A side
|
||||
/// may be a point (a three-sided patch). A curve that is a chain of a surface's open edges may
|
||||
/// carry G1 or G2 with the face beside it. `operation` join sews the surface into the body of
|
||||
/// its first edge curve (a solid when it closes the gap there).
|
||||
SurfaceNetwork {
|
||||
u: Vec<NetworkCurve>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
v: Vec<NetworkCurve>,
|
||||
#[serde(default)]
|
||||
operation: Operation,
|
||||
},
|
||||
/// One face of a surface body rebuilt against another body (`surface.match`): the rebuilt
|
||||
/// boundary nearest the chain of `edges` on `target` takes that chain's place and repeats the
|
||||
/// target's own shape along the seam up to `order`, while the boundary opposite it, and the
|
||||
@@ -1687,6 +1705,27 @@ pub struct BlendSide {
|
||||
pub edges: Vec<Position>,
|
||||
}
|
||||
|
||||
/// One curve of a boundary surface's network ([`FeatureKind::SurfaceNetwork`]): a chain of a
|
||||
/// sketch's `curves` (planar or 3D, meeting end to end), a chain of `body`'s edges picked by points
|
||||
/// on them (`edges`), or a side collapsed to the point `point` (the apex of a three-sided patch).
|
||||
/// A chain of a surface's open edges may carry `continuity` with the face beside it (G0, the
|
||||
/// default, G1 or G2).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
pub struct NetworkCurve {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sketch: Option<SketchId>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub curves: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<BodyName>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub edges: Vec<Position>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub point: Option<Position>,
|
||||
#[serde(default = "patch_g0", skip_serializing_if = "is_g0")]
|
||||
pub continuity: Continuity,
|
||||
}
|
||||
|
||||
/// How a patch on a loop of edges meets the faces beside the loop: every edge with `all`, but the
|
||||
/// edges picked in `edges` with their own.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
@@ -1728,9 +1767,12 @@ fn is_g0(g: &Continuity) -> bool {
|
||||
/// The continuity a blend repeats by default: the tangent plane on both sides.
|
||||
/// The stations a boundary patch carries by default.
|
||||
fn boundary_samples() -> usize {
|
||||
25
|
||||
BOUNDARY_SAMPLES
|
||||
}
|
||||
|
||||
/// The stations a boundary patch carries when a design does not say.
|
||||
pub const BOUNDARY_SAMPLES: usize = 25;
|
||||
|
||||
fn blend_orders() -> [Continuity; 2] {
|
||||
[Continuity::G1, Continuity::G1]
|
||||
}
|
||||
@@ -1773,6 +1815,10 @@ pub struct SketchPoints {
|
||||
/// Drill the other way: away from the side the holes would go into by themselves.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub flip: bool,
|
||||
/// A Single hole snapped to this one point (#2110): drilled at the point as it moves, along
|
||||
/// the hole's `direction` (into the face it was placed on) rather than across the sketch.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub single: bool,
|
||||
}
|
||||
|
||||
/// A component: a node of the design tree that owns sketches, features and bodies. The root
|
||||
@@ -2083,7 +2129,7 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature",
|
||||
FeatureKind::SurfaceLoft { .. } => "SurfaceLoftFeature",
|
||||
FeatureKind::SurfaceBlend { .. } => "BlendFeature",
|
||||
FeatureKind::SurfaceBoundary { .. } => "BoundaryFeature",
|
||||
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "BoundaryFeature",
|
||||
FeatureKind::SurfaceMatch { .. } => "MatchFeature",
|
||||
FeatureKind::SurfaceFair { .. } => "FairFeature",
|
||||
FeatureKind::Stitch { .. } => "StitchFeature",
|
||||
@@ -2163,7 +2209,7 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweep",
|
||||
FeatureKind::SurfaceLoft { .. } => "SurfaceLoft",
|
||||
FeatureKind::SurfaceBlend { .. } => "Blend",
|
||||
FeatureKind::SurfaceBoundary { .. } => "Boundary",
|
||||
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "Boundary",
|
||||
FeatureKind::SurfaceMatch { .. } => "Match",
|
||||
FeatureKind::SurfaceFair { .. } => "Fair",
|
||||
FeatureKind::Stitch { .. } => "Stitch",
|
||||
@@ -2378,6 +2424,12 @@ pub struct Visibility {
|
||||
/// Assembly joints by id: their glyphs are not drawn (they still constrain and move).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub hidden_joints: Vec<u64>,
|
||||
/// Browser folders and groups hidden themselves, by tree key: `<scope>/<folder>` or
|
||||
/// `<scope>/g<group id>`, where the scope is `c0` (the design) or `o:<placement path>`
|
||||
/// (`o:4/9/bodies`). Their items keep their own state: they are drawn again, as they were,
|
||||
/// once the folder is shown.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub hidden_folders: Vec<String>,
|
||||
}
|
||||
|
||||
impl Visibility {
|
||||
@@ -2389,6 +2441,7 @@ impl Visibility {
|
||||
&& self.hidden_sketches.is_empty()
|
||||
&& self.shown_sketches.is_empty()
|
||||
&& self.hidden_joints.is_empty()
|
||||
&& self.hidden_folders.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+287
-27
@@ -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.
|
||||
@@ -428,6 +430,12 @@ fn one_by_one(input: Traced, edges: &[Vec3], blend: impl Fn(&kernel::Body, Vec3)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Posts` for `Posts (1)`: the body a split piece was numbered after.
|
||||
fn split_piece_of(name: &str) -> Option<&str> {
|
||||
let (base, n) = name.strip_suffix(')')?.rsplit_once(" (")?;
|
||||
(!n.starts_with('0') && n.parse::<u32>().is_ok()).then_some(base)
|
||||
}
|
||||
|
||||
/// Add `note` to a feature's warning.
|
||||
fn append_warning(warning: &mut Option<String>, note: String) {
|
||||
*warning = Some(match warning.take() {
|
||||
@@ -1033,6 +1041,20 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
|
||||
}
|
||||
return hash.finish();
|
||||
}
|
||||
// A network through 3D sketch curves reads their points through the components the
|
||||
// feature and those sketches are placed in, as a sweep's wired path does.
|
||||
if let FeatureKind::SurfaceNetwork { u, v, .. } = &f.kind {
|
||||
let sketches: Vec<crate::SketchId> = u.iter().chain(v).filter_map(|c| c.sketch).collect();
|
||||
if sketches.iter().any(wired) {
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
doc.component_transform(f.component).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||||
for sketch in &sketches {
|
||||
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
|
||||
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||||
}
|
||||
return hash.finish();
|
||||
}
|
||||
}
|
||||
// To / From Object finds its target where that target's component is placed (#928).
|
||||
let to_object = match &f.kind {
|
||||
FeatureKind::Extrude { extent, .. } => extent.to.is_some() || extent.from.is_some(),
|
||||
@@ -1104,7 +1126,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;
|
||||
@@ -1257,7 +1287,7 @@ impl Model {
|
||||
// the face along the hole's axis and never slides sideways to the face's nearest
|
||||
// point when the face no longer reaches it.
|
||||
let hole_axis = match &f.kind {
|
||||
FeatureKind::Hole { direction, points: None, .. } => direction.normalized(),
|
||||
FeatureKind::Hole { direction, points: None | Some(crate::SketchPoints { single: true, .. }), .. } => direction.normalized(),
|
||||
_ => None,
|
||||
};
|
||||
let fst = face_point_state(doc, &state, &f.kind, f.component);
|
||||
@@ -1581,6 +1611,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 +1689,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
|
||||
@@ -1682,8 +1770,9 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
Operation::Cut | Operation::Intersect => {
|
||||
let bop = if op == Operation::Cut { BoolOp::Cut } else { BoolOp::Intersect };
|
||||
let tools = if op == Operation::Cut { clear_tools_as_one(tools) } else { tools };
|
||||
let affected: std::collections::BTreeSet<String> =
|
||||
target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect();
|
||||
// Each target's pieces before the cut: a body joined from separate pieces stays one.
|
||||
let affected: std::collections::BTreeMap<String, usize> =
|
||||
target_idx.iter().filter_map(|i| state.bodies.get(*i)).map(|b| (b.name.clone(), b.body.lumps().map_or(1, |l| l.len()))).collect();
|
||||
let mut remove = Vec::new();
|
||||
for &ti in &target_idx {
|
||||
let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.traced())) else { continue };
|
||||
@@ -1711,16 +1800,19 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
let live: std::collections::BTreeSet<&str> = state.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
state.threads.retain(|t| live.contains(t.body.as_str()));
|
||||
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)"),
|
||||
// numbered on past names already taken (a body cut in pieces before).
|
||||
// numbered on past names already taken (a body cut in pieces before). Only a cut that
|
||||
// made more pieces than the body had splits it: the pieces of a join stay together.
|
||||
// Not yet: a cut that splits one piece of such a body still splits all of them; keeping
|
||||
// the untouched ones together needs each new piece traced to the piece it came from.
|
||||
let mut taken: std::collections::BTreeSet<String> = state.bodies.iter().map(|b| b.name.clone()).collect();
|
||||
let mut split: Vec<ModelBody> = Vec::new();
|
||||
for mb in std::mem::take(&mut state.bodies) {
|
||||
if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) {
|
||||
let Some(&had) = affected.get(&mb.name).filter(|_| !state.sheets.iter().any(|s| s.body == mb.name)) else {
|
||||
split.push(mb);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let pieces = mb.body.lumps().unwrap_or_else(|_| vec![mb.body.clone()]);
|
||||
if pieces.len() < 2 {
|
||||
if pieces.len() <= had {
|
||||
split.push(mb);
|
||||
continue;
|
||||
}
|
||||
@@ -2141,6 +2233,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`
|
||||
@@ -2547,7 +2679,7 @@ fn sketch_hole_dir(st: &ModelState, spots: &[Vec3], n: Vec3) -> Vec3 {
|
||||
fn hole_spots(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
|
||||
let FeatureKind::Hole { position, at, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) };
|
||||
let (spots, dir) = match points {
|
||||
Some(sp) => {
|
||||
Some(sp) if !sp.single => {
|
||||
let ss =
|
||||
st.sketch(sp.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sp.sketch)))?;
|
||||
let mut v = Vec::new();
|
||||
@@ -2559,9 +2691,9 @@ fn hole_spots(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> R
|
||||
let dir = if sp.flip { -dir } else { dir };
|
||||
(v, dir)
|
||||
}
|
||||
None => {
|
||||
_ => {
|
||||
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
|
||||
let at = hole_on_surface(st, hole_position(vals, position, at)?, dir);
|
||||
let at = hole_on_surface(st, single_hole_position(vals, st, position, at, points)?, dir);
|
||||
let dir = hole_dir_into_face(st, at, dir);
|
||||
return Ok((vec![hole_on_surface(st, at, dir)], dir));
|
||||
}
|
||||
@@ -2598,7 +2730,7 @@ fn hole_depth_to(st: &ModelState, at: &Vec3, dir: Vec3, to: &Option<Vec3>, depth
|
||||
fn hole_spots_raw(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
|
||||
let FeatureKind::Hole { position, at, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) };
|
||||
match points {
|
||||
Some(sp) => {
|
||||
Some(sp) if !sp.single => {
|
||||
let ss = st.sketch(sp.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {}", sp.sketch)))?;
|
||||
let v: Vec<Vec3> =
|
||||
sp.ids.iter().filter_map(|id| ss.sketch.points.iter().find(|p| &p.id == id)).map(|p| ss.plane.to_world(p.pos)).take(10_000).collect();
|
||||
@@ -2606,14 +2738,30 @@ fn hole_spots_raw(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature)
|
||||
let dir = if sp.flip { -dir } else { dir };
|
||||
Ok((v, dir))
|
||||
}
|
||||
None => {
|
||||
_ => {
|
||||
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
|
||||
let p = hole_position(vals, position, at)?;
|
||||
let p = single_hole_position(vals, st, position, at, points)?;
|
||||
Ok((vec![p], hole_dir_into_face(st, hole_on_surface(st, p, dir), dir)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a hole placed by position is drilled: at its sketch point where the sketch has it now,
|
||||
/// for a Single hole snapped to one (#2110), else as [`hole_position`].
|
||||
fn single_hole_position(
|
||||
vals: &BTreeMap<String, Value>,
|
||||
st: &ModelState,
|
||||
position: &crate::FacePick,
|
||||
at: &Option<crate::PointExpr>,
|
||||
points: &Option<crate::SketchPoints>,
|
||||
) -> Result<Vec3> {
|
||||
let Some(sp) = points else { return hole_position(vals, position, at) };
|
||||
let ss = st.sketch(sp.sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sp.sketch)))?;
|
||||
let id = sp.ids.first().ok_or_else(|| DocError::Invalid("a Single hole needs its sketch point".into()))?;
|
||||
let p = ss.sketch.points.iter().find(|p| &p.id == id).ok_or_else(|| DocError::Unknown(format!("sketch point `{id}`")))?;
|
||||
Ok(ss.plane.to_world(p.pos))
|
||||
}
|
||||
|
||||
/// Where a hole placed by position is drilled: its `at` expressions evaluated, so it follows the
|
||||
/// parameters (#1678), else the point it was given.
|
||||
fn hole_position(vals: &BTreeMap<String, Value>, position: &crate::FacePick, at: &Option<crate::PointExpr>) -> Result<Vec3> {
|
||||
@@ -2957,7 +3105,8 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
|
||||
};
|
||||
// Fit cap surfaces in their own coordinates. Fitting a distant world-space
|
||||
// wire can tilt an otherwise exact planar cap through numeric cancellation.
|
||||
let bodies = kernel::extrude(&Plane::XY, std::slice::from_ref(region), start, end)?;
|
||||
// A side drawn in collinear pieces is one face (#1788).
|
||||
let bodies = kernel::extrude_whole_sides(&Plane::XY, std::slice::from_ref(region), start, end)?;
|
||||
if *plane == Plane::XY {
|
||||
return Ok(bodies.into_iter().map(|body| roles.tool(body, None)).collect());
|
||||
}
|
||||
@@ -3068,21 +3217,30 @@ fn feature_tools_with_constructor(
|
||||
};
|
||||
if let Some(tp) = &extent.taper {
|
||||
let taper = val(vals, tp, Kind::Angle)?;
|
||||
if extent.distance2.is_some() || extent.start_offset.is_some() || extent.direction == crate::Direction::Symmetric {
|
||||
return Err(DocError::Invalid("not supported yet: taper with two-sided, symmetric or offset extents".into()));
|
||||
if extent.distance2.is_some() || extent.direction == crate::Direction::Symmetric {
|
||||
return Err(DocError::Invalid("not supported yet: taper with two-sided or symmetric extents".into()));
|
||||
}
|
||||
let sign = if extent.to.is_none() && extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 };
|
||||
let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) };
|
||||
let start_plane = ss.plane.offset(from);
|
||||
// Where the extrude starts: the From Object's level and a start offset along the
|
||||
// normal from it. The offset moves the profile that far and the sides are drafted
|
||||
// from there, so the profile stays the section at the start whatever the offset
|
||||
// (`d` is measured from the start already, To Object included).
|
||||
let base = from
|
||||
+ match &extent.start_offset {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let start_plane = ss.plane.offset(base);
|
||||
let caps = [
|
||||
Some(crate::lineage::extrude_cap_role(from)),
|
||||
Some(crate::lineage::extrude_cap_role(if sign < 0.0 { from - len } else { from + len })),
|
||||
Some(crate::lineage::extrude_cap_role(base)),
|
||||
Some(crate::lineage::extrude_cap_role(if sign < 0.0 { base - len } else { base + len })),
|
||||
];
|
||||
// How far a tool grows past its profile at either end, at most.
|
||||
let mut growth: f64 = 0.0;
|
||||
// A face of the targets in the profile's plane (the same for every profile).
|
||||
let plain_targets: Vec<String> = crate::plain(targets);
|
||||
let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, &plain_targets);
|
||||
let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, base, &plain_targets);
|
||||
let surroundings = Surroundings::of(st, *operation, &plain_targets);
|
||||
let tools: Vec<Traced> = regions
|
||||
.iter()
|
||||
@@ -3106,9 +3264,9 @@ fn feature_tools_with_constructor(
|
||||
growth = growth.max(len * taper.tan().abs());
|
||||
return Ok(roles.tool(exact, None));
|
||||
}
|
||||
let (lo, hi) = if sign < 0.0 { (from - len, from) } else { (from, from + len) };
|
||||
let (lo, hi) = if sign < 0.0 { (base - len, base) } else { (base, base + len) };
|
||||
let (l2, h2) = surroundings.as_ref().map_or((lo, hi), |sur| extend_for_coplanar(sur, &ss.plane, r, lo, hi));
|
||||
let (mut a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) };
|
||||
let (mut a, b) = if sign < 0.0 { (base - h2, base - l2) } else { (l2 - base, h2 - base) };
|
||||
// A taper that narrows the tool away from the face widens it as it runs on into it, past
|
||||
// the profile (where the profile is the whole face, out through the body's sides, #1534):
|
||||
// then it starts on the face instead.
|
||||
@@ -3179,8 +3337,63 @@ fn feature_tools_with_constructor(
|
||||
// Each tool's extent along the normal (they differ where one is run on into a face).
|
||||
let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len());
|
||||
let plain_targets: Vec<String> = crate::plain(targets);
|
||||
let surroundings = Surroundings::of(st, *operation, &plain_targets);
|
||||
for r in ®ions {
|
||||
let exact_planar_cut = *operation == Operation::Cut
|
||||
&& ss.sketch.links.len() > 1
|
||||
&& ss.sketch.links.iter().all(|link| matches!(&link.source, solidcraft_sketch::LinkSource::Face { .. }))
|
||||
&& st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|body| plain_targets.is_empty() || plain_targets.iter().any(|target| target == "*" || target == &body.name))
|
||||
.all(|body| kernel::is_planar(&body.body));
|
||||
let mut touching_batch = false;
|
||||
// Generated face profiles have full source seams. Check all of them in
|
||||
// one guarded construction; arbitrary sketch profiles retain their existing path.
|
||||
if *operation == Operation::Join
|
||||
&& regions.len() > 1
|
||||
&& ss.sketch.links.len() > 1
|
||||
&& ss.sketch.links.iter().all(|link| matches!(&link.source, solidcraft_sketch::LinkSource::Face { .. }))
|
||||
{
|
||||
let exact = regions
|
||||
.iter()
|
||||
.map(|region| centered_extrude(&ss.plane, region, lo + off, hi + off, Some(&ss)))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
if let Ok(Some(compound)) = kernel::compound_disjoint_extrusions(&exact.iter().map(|tool| tool.body.clone()).collect::<Vec<_>>())
|
||||
&& st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|body| plain_targets.is_empty() || plain_targets.contains(&body.name))
|
||||
.any(|body| kernel::can_join_all_touching_faces(&body.body, &compound))
|
||||
{
|
||||
tools = exact;
|
||||
touching_batch = true;
|
||||
}
|
||||
}
|
||||
let surroundings = if exact_planar_cut || touching_batch { None } else { Surroundings::of(st, *operation, &plain_targets) };
|
||||
let join_surroundings = if *operation == Operation::Join && !touching_batch {
|
||||
// Trimming may discard a tool's material, so only bodies this feature
|
||||
// actually joins can establish that the discarded end is already filled.
|
||||
let participants = if plain_targets.is_empty() { &f.participants } else { &plain_targets };
|
||||
let trim_targets: Vec<String> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
if participants.is_empty() {
|
||||
b.component == f.component || b.feature == f.id
|
||||
} else {
|
||||
participants.iter().any(|t| t == "*" || t == &b.name)
|
||||
}
|
||||
})
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
|
||||
if trim_targets.is_empty() { None } else { Surroundings::of(st, *operation, &trim_targets) }
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for r in regions.iter().filter(|_| !touching_batch) {
|
||||
if *operation == Operation::Join {
|
||||
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
|
||||
if exact.iter().all(|tool| {
|
||||
@@ -3198,6 +3411,12 @@ fn feature_tools_with_constructor(
|
||||
}
|
||||
}
|
||||
let (l2, h2) = surroundings.as_ref().map_or((lo + off, hi + off), |sur| extend_for_coplanar(sur, &ss.plane, r, lo + off, hi + off));
|
||||
let (l2, h2) = join_surroundings.as_ref().map_or((l2, h2), |sur| {
|
||||
// Trim only material in the requested extent. A run-on added
|
||||
// above to resolve a touching cap must keep doing its job.
|
||||
let (l, h) = trim_embedded_join_ends(sur, &ss.plane, r, lo + off, hi + off);
|
||||
(if l > lo + off { l } else { l2 }, if h < hi + off { h } else { h2 })
|
||||
});
|
||||
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
|
||||
let original = r;
|
||||
let grown = |gains: &dyn Fn(&[[Vec2; 3]]) -> bool| match &surroundings {
|
||||
@@ -4522,6 +4741,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::SurfaceLoft { .. }
|
||||
| FeatureKind::SurfaceBlend { .. }
|
||||
| FeatureKind::SurfaceBoundary { .. }
|
||||
| FeatureKind::SurfaceNetwork { .. }
|
||||
| FeatureKind::SurfaceMatch { .. }
|
||||
| FeatureKind::SurfaceFair { .. }
|
||||
| FeatureKind::SurfaceOffset { .. }
|
||||
@@ -4576,8 +4796,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, .. }
|
||||
@@ -5056,11 +5277,29 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
let mut tb = Vec::new();
|
||||
for t in tools {
|
||||
tb.push(st.body(t).ok_or_else(|| DocError::Unknown(format!("body `{t}`")))?.traced());
|
||||
match st.body(t) {
|
||||
Some(b) => tb.push(b.traced()),
|
||||
// Designs saved before #2247 combined the pieces a cut split off a body that
|
||||
// was already several pieces (`Posts`, `Posts (1)`). A cut keeps them in one
|
||||
// body now: the piece is in `Posts`, which is a tool too or the body joined to.
|
||||
None => match split_piece_of(t) {
|
||||
Some(base)
|
||||
if st.body(base).is_some()
|
||||
&& (tools.iter().any(|o| **o == *base)
|
||||
|| (**target == *base && matches!(operation, Operation::Join | Operation::NewBody))) =>
|
||||
{
|
||||
append_warning(warning, format!("`{t}` is part of `{base}` now"));
|
||||
}
|
||||
_ => return Err(DocError::Unknown(format!("body `{t}`"))),
|
||||
},
|
||||
}
|
||||
}
|
||||
if st.body(target).is_none() {
|
||||
return Err(DocError::Unknown(format!("body `{target}`")));
|
||||
}
|
||||
if tb.is_empty() && !tools.is_empty() {
|
||||
return Ok(()); // the pieces were all joined back into the body they are part of
|
||||
}
|
||||
let op = if *operation == Operation::NewBody { Operation::Join } else { *operation };
|
||||
apply_traced(st, f, tb, op, std::slice::from_ref(&target.0))?;
|
||||
if !keep_tools {
|
||||
@@ -5734,6 +5973,27 @@ mod body_name_tests {
|
||||
mod coplanar_growth_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn buried_join_ends_stop_at_material_boundaries() {
|
||||
let block = kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 8.0)).unwrap();
|
||||
let profile = rect(2.0, 2.0, 4.0, 4.0);
|
||||
let st = state(vec![block.clone()]);
|
||||
let sur = Surroundings::of(&st, Operation::Join, &[]).unwrap();
|
||||
assert_eq!(trim_embedded_join_ends(&sur, &Plane::XY, &profile, 0.0, 16.0), (8.0, 16.0));
|
||||
assert_eq!(trim_embedded_join_ends(&sur, &Plane::XY, &profile, -8.0, 8.0), (-8.0, 0.0));
|
||||
// A thin sealed void between the old start and the rib's top must still
|
||||
// be filled by the join. The remaining tool must start before that void.
|
||||
let void = kernel::box_solid(Vec3::new(1.0, 1.0, 3.01), Vec3::new(5.0, 5.0, 3.02)).unwrap();
|
||||
let hollow = kernel::boolean(&block, &void, BoolOp::Cut).unwrap().unwrap();
|
||||
let st = state(vec![hollow]);
|
||||
let sur = Surroundings::of(&st, Operation::Join, &[]).unwrap();
|
||||
let (lo, hi) = trim_embedded_join_ends(&sur, &Plane::XY, &profile, 0.0, 16.0);
|
||||
assert!(lo <= 3.01, "trimming past {lo} would discard material the join adds in the void");
|
||||
assert_eq!(hi, 16.0);
|
||||
let cut = Surroundings::of(&st, Operation::Cut, &[]).unwrap();
|
||||
assert_eq!(trim_embedded_join_ends(&cut, &Plane::XY, &profile, 0.0, 16.0), (0.0, 16.0));
|
||||
}
|
||||
|
||||
fn state(bodies: Vec<Body>) -> ModelState {
|
||||
ModelState { bodies: bodies.into_iter().enumerate().map(|(i, b)| ModelBody::new(format!("Body{i}"), b, 0)).collect(), ..Default::default() }
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
apply_op(st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, &crate::plain(targets))
|
||||
}
|
||||
FeatureKind::Rib { sketch, curves, thickness, depth, flip, web } => {
|
||||
FeatureKind::Rib { sketch, curves, thickness, depth, flip, web, normal } => {
|
||||
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
let t = val(vals, thickness, Kind::Length)?;
|
||||
if !(t > 0.0) {
|
||||
@@ -88,7 +88,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
let mut tools = Vec::new();
|
||||
for g in &groups {
|
||||
tools.push(rib_tool(st, &ss, g, t, depth, *flip)?);
|
||||
tools.push(if *normal { rib_tool_normal(st, &ss, g, t, depth, *flip)? } else { rib_tool(st, &ss, g, t, depth, *flip)? });
|
||||
}
|
||||
apply_op(st, f, tools, Operation::Join, &[])
|
||||
}
|
||||
@@ -286,6 +286,83 @@ fn rib_tool(st: &ModelState, ss: &SolvedSketch, ids: &[String], t: f64, depth: O
|
||||
tools.into_iter().next().ok_or_else(|| DocError::Invalid("the rib made no solid".into()))
|
||||
}
|
||||
|
||||
/// A rib wall along the sketch normal: the curves widened to `t` in the sketch plane, run from
|
||||
/// the sketch toward the body by `depth` (or until the whole wall reaches the body).
|
||||
fn rib_tool_normal(st: &ModelState, ss: &SolvedSketch, ids: &[String], t: f64, depth: Option<f64>, flip: bool) -> Result<Body> {
|
||||
let pts = chain_points(ss, ids)?;
|
||||
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
if !(len > 1e-9) {
|
||||
return Err(DocError::Invalid("a zero-length rib curve".into()));
|
||||
}
|
||||
let samples = ((len / (t * 0.5).max(len / 400.0)).ceil() as usize).clamp(2, 400);
|
||||
let along: Vec<Vec3> = (0..=samples).map(|k| ss.plane.to_world(point_on_polyline(&pts, len * k as f64 / samples as f64))).collect();
|
||||
let normal = ss.plane.normal();
|
||||
let reach = st.bodies.iter().map(|b| b.mesh().bounds()).fold(0.0f64, |acc, bb| acc.max((bb.max - bb.min).len()));
|
||||
let inside = |q: Vec3| st.bodies.iter().any(|b| b.mesh().contains(q));
|
||||
// Where the body begins from each point of the curves, along `sign` × the sketch normal.
|
||||
let limit = reach * 2.0 + 1.0;
|
||||
let step = (limit / 400.0).max(1e-3);
|
||||
let hits = |sign: f64| -> Vec<f64> {
|
||||
let mut out = Vec::new();
|
||||
for q in &along {
|
||||
let Some(k) = (1..=400).find(|k| inside(*q + normal * (sign * *k as f64 * step))) else { continue };
|
||||
let (mut lo, mut hi) = ((k - 1) as f64 * step, k as f64 * step);
|
||||
for _ in 0..30 {
|
||||
let m = 0.5 * (lo + hi);
|
||||
if inside(*q + normal * (sign * m)) { hi = m } else { lo = m }
|
||||
}
|
||||
out.push(hi);
|
||||
}
|
||||
out
|
||||
};
|
||||
// Toward the material: the side of the sketch where the curves meet the bodies (unless flipped).
|
||||
let (front, back) = (hits(1.0), hits(-1.0));
|
||||
let mut sign = if back.len() > front.len() { -1.0 } else { 1.0 };
|
||||
let mut reached = if sign > 0.0 { front } else { back };
|
||||
if flip {
|
||||
sign = -sign;
|
||||
reached = hits(sign);
|
||||
}
|
||||
let h = match depth {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let far = reached.iter().copied().fold(f64::NAN, f64::max);
|
||||
if !far.is_finite() {
|
||||
return Err(DocError::Invalid("the rib curves do not face a body along the sketch normal (give a depth)".into()));
|
||||
}
|
||||
far + (t * 0.5).max(step)
|
||||
}
|
||||
};
|
||||
// The curves widened to `t`: each point offset by half the thickness along the miter of its
|
||||
// neighbours, both sides joined into one band.
|
||||
let half = t / 2.0;
|
||||
let mut left = Vec::with_capacity(pts.len());
|
||||
let mut right = Vec::with_capacity(pts.len());
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
let prev = i.checked_sub(1).and_then(|j| pts.get(j)).and_then(|q| (*p - *q).normalized());
|
||||
let next = pts.get(i + 1).and_then(|q| (*q - *p).normalized());
|
||||
let (n, scale) = match (prev, next) {
|
||||
(Some(a), Some(b)) => {
|
||||
let m = (a.perp() + b.perp()).normalized().unwrap_or(a.perp());
|
||||
(m, 1.0 / m.dot(a.perp()).max(0.25))
|
||||
}
|
||||
(Some(a), None) | (None, Some(a)) => (a.perp(), 1.0),
|
||||
(None, None) => continue,
|
||||
};
|
||||
left.push(*p + n * (half * scale));
|
||||
right.push(*p - n * (half * scale));
|
||||
}
|
||||
left.extend(right.into_iter().rev());
|
||||
let mut lp = Loop2::polygon(&left);
|
||||
if lp.signed_area() < 0.0 {
|
||||
lp = lp.reversed();
|
||||
}
|
||||
let region = Region2 { outer: lp, holes: Vec::new() };
|
||||
let (a, b) = if sign > 0.0 { (0.0, h) } else { (-h, 0.0) };
|
||||
let tools = kernel::extrude(&ss.plane, &[region], a, b)?;
|
||||
tools.into_iter().next().ok_or_else(|| DocError::Invalid("the rib made no solid".into()))
|
||||
}
|
||||
|
||||
/// The point `d` along a polyline.
|
||||
fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 {
|
||||
let mut left = d.max(0.0);
|
||||
|
||||
@@ -151,12 +151,49 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
|
||||
let body = kernel::boundary_patch(&boundaries, *crown, *samples)?;
|
||||
// Two curves are the two sides of a bridge. Three or four run round a loop: the Coons
|
||||
// patch of the loop, which lies in the sketch's plane with them, so every order holds.
|
||||
let body = if curves.len() >= 3 {
|
||||
if *crown != 0.0 {
|
||||
return Err(DocError::Invalid(
|
||||
"not supported yet: crown on a patch through three or four curves (it is the flat patch of its sides)".into(),
|
||||
));
|
||||
}
|
||||
loop_patch(&ss, curves)?
|
||||
} else {
|
||||
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
|
||||
kernel::boundary_patch(&boundaries, *crown, *samples)?
|
||||
};
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, body, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceNetwork { u, v, operation } => {
|
||||
if !matches!(operation, crate::Operation::NewBody | crate::Operation::Join) {
|
||||
return Err(DocError::Invalid("a boundary surface is a new body or joins the body of its edges".into()));
|
||||
}
|
||||
let us = network_sources(doc, st, f, u, "U")?;
|
||||
let vs = network_sources(doc, st, f, v, "V")?;
|
||||
let made = kernel::boundary_surface(&network_curves(&us), &network_curves(&vs))?;
|
||||
if *operation == crate::Operation::Join {
|
||||
// Into the body of the first curve picked on a body's edges.
|
||||
let target = u
|
||||
.iter()
|
||||
.chain(v)
|
||||
.find_map(|c| c.body.clone())
|
||||
.ok_or_else(|| DocError::Invalid("join needs a curve on a body's edges: the surface is sewn into that body".into()))?;
|
||||
let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Unknown(format!("body `{target}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let joined = kernel::boundary_join(&mb.body, &made)?;
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, joined, mb.feature);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, made, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceMatch { body, faces, target, edges, order } => {
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
@@ -439,6 +476,135 @@ fn push_sheet(st: &mut ModelState, f: &Feature, traced: crate::lineage::Traced)
|
||||
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
|
||||
}
|
||||
|
||||
/// The most curves one direction of a network may list (the kernel's own cap).
|
||||
const MAX_NETWORK_CURVES: usize = solidcraft_geom::network::MAX_NETWORK_CURVES;
|
||||
|
||||
/// A loop of three or four sketch curves as one patch: four give the Coons patch of opposite pairs
|
||||
/// (the first and third one way, the second and fourth the other); three collapse the side
|
||||
/// opposite the first curve to the corner where the other two meet.
|
||||
fn loop_patch(ss: &SolvedSketch, curves: &[String]) -> Result<kernel::Body> {
|
||||
let mut chains: Vec<Vec<Seg2>> = Vec::with_capacity(curves.len());
|
||||
for id in curves {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("sketch curve `{id}`")))?;
|
||||
let segs = ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci));
|
||||
if segs.is_empty() {
|
||||
return Err(DocError::Invalid(format!("curve `{id}` has no geometry to build on")));
|
||||
}
|
||||
chains.push(segs);
|
||||
}
|
||||
let side = |k: usize| -> Result<kernel::BoundaryCurve<'static>> {
|
||||
let (Some(id), Some(segs)) = (curves.get(k), chains.get(k)) else { return Err(DocError::Invalid("a boundary curve is missing".into())) };
|
||||
Ok(kernel::BoundaryCurve {
|
||||
label: format!("curve `{id}`"),
|
||||
source: kernel::CurveSource::Sketch { plane: ss.plane, segs: segs.clone() },
|
||||
continuity: kernel::Continuity::G0,
|
||||
})
|
||||
};
|
||||
match curves.len() {
|
||||
4 => Ok(kernel::boundary_surface(&[side(0)?, side(2)?], &[side(1)?, side(3)?])?),
|
||||
3 => {
|
||||
// The corner the second and third curves share, whichever way each was drawn.
|
||||
let ends = |k: usize| -> Option<[Vec2; 2]> {
|
||||
let c = chains.get(k)?;
|
||||
Some([c.first()?.start(), c.last()?.end()])
|
||||
};
|
||||
let (Some(b), Some(c)) = (ends(1), ends(2)) else { return Err(DocError::Invalid("a boundary curve is missing".into())) };
|
||||
let mut best = (f64::INFINITY, Vec2::new(0.0, 0.0));
|
||||
for p in b {
|
||||
for q in c {
|
||||
if p.dist(q) < best.0 {
|
||||
best = (p.dist(q), (p + q) * 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
let apex = kernel::BoundaryCurve {
|
||||
label: "the corner opposite the first curve".into(),
|
||||
source: kernel::CurveSource::Point(ss.plane.to_world(best.1)),
|
||||
continuity: kernel::Continuity::G0,
|
||||
};
|
||||
Ok(kernel::boundary_surface(&[side(0)?, apex], &[side(1)?, side(2)?])?)
|
||||
}
|
||||
_ => Err(DocError::Invalid("a loop patch takes three or four curves".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A network curve resolved in the feature's frame, owning what it is built from.
|
||||
enum NetSource {
|
||||
Sketch(solidcraft_geom::Plane, Vec<Seg2>),
|
||||
Points(Vec<Vec3>),
|
||||
Edges(kernel::Body, Vec<Vec3>),
|
||||
Point(Vec3),
|
||||
}
|
||||
|
||||
/// The curves of one direction of a network, resolved: each one sketch chain, 3D sketch chain,
|
||||
/// edge chain or point, labelled `U1`, `U2`, … (with `dir` "U") for messages.
|
||||
fn network_sources(
|
||||
doc: &Document,
|
||||
st: &ModelState,
|
||||
f: &Feature,
|
||||
list: &[crate::NetworkCurve],
|
||||
dir: &str,
|
||||
) -> Result<Vec<(String, NetSource, kernel::Continuity)>> {
|
||||
if list.len() > MAX_NETWORK_CURVES {
|
||||
return Err(DocError::Invalid(format!("at most {MAX_NETWORK_CURVES} curves each way")));
|
||||
}
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for (k, c) in list.iter().enumerate() {
|
||||
let label = format!("{dir}{}", k + 1);
|
||||
let given = [c.sketch.is_some() || !c.curves.is_empty(), c.body.is_some() || !c.edges.is_empty(), c.point.is_some()];
|
||||
if given.iter().filter(|g| **g).count() != 1 {
|
||||
return Err(DocError::Invalid(format!("{label} must be sketch curves, a body's edges or a point")));
|
||||
}
|
||||
let source = if let Some(p) = c.point {
|
||||
NetSource::Point(p.0)
|
||||
} else if let Some(name) = &c.body {
|
||||
if c.edges.is_empty() {
|
||||
return Err(DocError::Invalid(format!("{label} needs points on the edges of {name}")));
|
||||
}
|
||||
let b = st.body(name).ok_or_else(|| DocError::Unknown(format!("body `{name}`")))?;
|
||||
NetSource::Edges(b.body.clone(), crate::plain(&c.edges))
|
||||
} else {
|
||||
let sk = c.sketch.ok_or_else(|| DocError::Invalid(format!("{label} needs the sketch its curves are in")))?;
|
||||
if c.curves.is_empty() || c.curves.len() > MAX_CHAIN_CURVES {
|
||||
return Err(DocError::Invalid(format!("{label} needs 1 to {MAX_CHAIN_CURVES} curves of its sketch")));
|
||||
}
|
||||
let raw = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
|
||||
// 3D sketch curves are followed through their points; planar ones exactly.
|
||||
let mapped = path_in_frame(doc, raw, f.component)?;
|
||||
match path_points(&mapped, &c.curves, Vec3::new(f64::NAN, f64::NAN, f64::NAN))? {
|
||||
Some(pts) => NetSource::Points(pts),
|
||||
None => {
|
||||
let ss = sketch_in_feature_frame(doc, raw, f.component)?;
|
||||
let mut chains = curve_chains(&ss, &c.curves)?.into_iter();
|
||||
let (Some(chain), None) = (chains.next(), chains.next()) else {
|
||||
return Err(DocError::Invalid(format!("{label}: its curves must meet end to end in one chain")));
|
||||
};
|
||||
NetSource::Sketch(ss.plane, chain)
|
||||
}
|
||||
}
|
||||
};
|
||||
out.push((label, source, c.continuity));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The kernel's curves over resolved sources (borrowing their bodies).
|
||||
fn network_curves(sources: &[(String, NetSource, kernel::Continuity)]) -> Vec<kernel::BoundaryCurve<'_>> {
|
||||
sources
|
||||
.iter()
|
||||
.map(|(label, src, g)| kernel::BoundaryCurve {
|
||||
label: label.clone(),
|
||||
source: match src {
|
||||
NetSource::Sketch(plane, segs) => kernel::CurveSource::Sketch { plane: *plane, segs: segs.clone() },
|
||||
NetSource::Points(pts) => kernel::CurveSource::Points(pts.clone()),
|
||||
NetSource::Edges(body, at) => kernel::CurveSource::Edges { body, at: at.clone() },
|
||||
NetSource::Point(p) => kernel::CurveSource::Point(*p),
|
||||
},
|
||||
continuity: *g,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The boundaries `surface.boundary` builds its patch from: each picked curve's own stations in
|
||||
/// the sketch's frame, every one of them leaving the boundary square into the loop — the direction
|
||||
/// from the curve towards the middle of the loop, taken across its tangent — over the plane the
|
||||
|
||||
@@ -164,6 +164,10 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
// The Class-A surface family (surface.blend/boundary/match/fair).
|
||||
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
|
||||
("surface_boundary", json!({"type": "surface_boundary", "sketch": 1, "curves": ["l1", "l2"]})),
|
||||
(
|
||||
"surface_network",
|
||||
json!({"type": "surface_network", "u": [{"sketch": 1, "curves": ["l1"]}, {"body": "B", "edges": [o], "continuity": "g1"}], "v": [{"sketch": 2, "curves": ["l1"]}, {"point": o}], "operation": "join"}),
|
||||
),
|
||||
("surface_match", json!({"type": "surface_match", "body": "B", "faces": [o], "target": "C", "edges": [o]})),
|
||||
("surface_fair", json!({"type": "surface_fair", "body": "B", "faces": [o], "tolerance": "sfair.tolerance"})),
|
||||
("unstitch", json!({"type": "unstitch", "body": "B"})),
|
||||
|
||||
@@ -342,6 +342,7 @@ surface_offset
|
||||
expressions: soff.distance, soff.tolerance
|
||||
surface_blend
|
||||
surface_boundary
|
||||
surface_network
|
||||
surface_match
|
||||
surface_fair
|
||||
inputs:
|
||||
|
||||
@@ -157,6 +157,8 @@ surface_offset
|
||||
surface_blend
|
||||
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
|
||||
surface_boundary
|
||||
surface_network
|
||||
moves: u[1].edges[0][0], u[1].edges[0][1], u[1].edges[0][2], v[1].point[0], v[1].point[1], v[1].point[2]
|
||||
surface_match
|
||||
moves: edges[0][0], edges[0][1], edges[0][2], faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
|
||||
@@ -182,6 +182,10 @@ surface_blend
|
||||
surface_boundary
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
surface_network
|
||||
sketches: [1, 2]
|
||||
remap_refs: u[0].sketch, v[0].sketch
|
||||
remap_bodies: u[1].body
|
||||
surface_match
|
||||
remap_bodies: body
|
||||
surface_fair
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A joint origin can sit on an edge of a planar face (`Snap::Edge`, saved as `"type": "edge"`):
|
||||
//! at the edge's middle or at a corner where it meets another edge, all three found again by name.
|
||||
//! Older designs need no transformation: they have no such origins. The last release refuses a
|
||||
//! design saved with this (the newer format number).
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A face or circle joint origin given no `x` reference keeps the x its frame had when picked
|
||||
//! (`x` as a direction, component coordinates) and lays it onto the face as it is now, so a part
|
||||
//! on a face that tilts keeps its turn. Older designs need no transformation: an origin without
|
||||
//! `x` works its frame out from the normal alone, as before. The last release refuses a design
|
||||
//! saved with this (the newer format number).
|
||||
@@ -0,0 +1,7 @@
|
||||
//! A `SurfaceNetwork` (Boundary Surface through a curve network, #763) saves `u` and `v`, each a
|
||||
//! list of curves: a `sketch` and its `curves`, a `body` and points on its `edges`, or a `point`
|
||||
//! (the apex of a three-sided patch), with an optional `continuity` (`g1` or `g2` with the face
|
||||
//! beside an edge chain), and an `operation` (`new_body`, or `join` into the body of its edges).
|
||||
//! A `SurfaceBoundary` of three or four curves, which older builds refused, now builds and saves
|
||||
//! as it always did. Older designs need no transformation; the last release asks for an update
|
||||
//! instead of meeting a feature it can't read.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A browser folder or group can be hidden on its own (`visibility.hidden_folders`, tree keys
|
||||
//! such as `c0/bodies`, `o:4/sketches` or `c0/g7`): its items keep their own hidden state and
|
||||
//! are drawn again as they were once it is shown. Older designs need no transformation: they
|
||||
//! hide no folder, and a folder's eye then follows its own state. The last release asks for an
|
||||
//! update instead of opening a design with this format number.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A cut keeps a body that is several separate pieces (posts joined while apart) as one body; it
|
||||
//! splits off only the pieces the cut itself made (#2247). Older designs need no transformation:
|
||||
//! where they named a piece the cut used to split off next to its body in a combine (tools
|
||||
//! `Posts`, `Posts (1)`), the combine takes `Posts` and warns that the piece is part of it. 0.15.0
|
||||
//! asks for an update instead of splitting the pieces apart again.
|
||||
@@ -0,0 +1,8 @@
|
||||
//! A face-centre or circle-centre joint origin can carry `x`: what turns its frame about z (a
|
||||
//! direction, `[x, y, z]`, or `{face, pick?}`: a face by its persistent name), and a
|
||||
//! circle-centre one `end`: the name of the face across the rim at the end it takes, so a part
|
||||
//! stays on the same side of a hole when the design changes. A named face is now resolved by its
|
||||
//! own plane or cylinder rather than the face nearest the pick. Older designs need no
|
||||
//! transformation: an origin without `end` takes the end nearer its pick, and one without `x`
|
||||
//! any direction square to z, as before; 0.14.x asks for an update instead of dropping a saved
|
||||
//! end or turn.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A Single hole picked at a sketch point keeps that point (`points` with `single: true`) and is
|
||||
//! drilled where the point is after the sketch changes, along its own direction into the face
|
||||
//! (#2110). Older designs need no transformation: a Single hole without it stays at the position
|
||||
//! it was placed at, as before; 0.15.x asks for an update instead of opening one that has it.
|
||||
+201
-37
@@ -36,18 +36,43 @@ pub enum Snap {
|
||||
x: Option<Vec3>,
|
||||
},
|
||||
/// The centre of the planar face nearest `pick`, z along its outward normal. `face` is the
|
||||
/// face's persistent name (found first).
|
||||
/// face's persistent name: when it names a planar face, that face's own plane and centre are
|
||||
/// taken (the pick only tells the pieces of a split face apart).
|
||||
FaceCenter {
|
||||
pick: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
face: Option<String>,
|
||||
/// What turns the frame about z (default: any direction square to z).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
x: Option<XRef>,
|
||||
},
|
||||
/// The centre of the circular end nearest `pick` of the cylindrical face there, z along its
|
||||
/// axis (pointing out of the face's end).
|
||||
/// The centre of a circular end of the cylindrical face at `pick`, z along its axis
|
||||
/// (pointing out of the face's end). `face` is the face's persistent name: when it names a
|
||||
/// cylindrical face, that face's own axis, radius and extent are taken. `end` is the name of
|
||||
/// the face across the rim at the chosen end (the plate's face a hole opens onto), so the
|
||||
/// end stays the same when the design changes; without it (or when neither end has that
|
||||
/// face any more), the end nearer `pick` along the axis.
|
||||
CircleCenter {
|
||||
pick: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
face: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
end: Option<String>,
|
||||
/// What turns the frame about z (default: any direction square to z).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
x: Option<XRef>,
|
||||
},
|
||||
/// A point on an edge of a planar face: the edge's middle, or the corner where it meets the
|
||||
/// edge `corner` names (`joints_edge.rs`). `edge` and `face` are the persistent names of the
|
||||
/// edge and the face (found first); `pick` is a point on the edge as it was picked.
|
||||
Edge {
|
||||
pick: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
edge: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
face: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
corner: Option<String>,
|
||||
},
|
||||
/// A point, z along `z` (default +Z).
|
||||
Point {
|
||||
@@ -57,6 +82,103 @@ pub enum Snap {
|
||||
},
|
||||
}
|
||||
|
||||
/// What sets a joint origin's x axis (its turn about z): a direction, or a face found by its
|
||||
/// persistent name, so a round origin keeps its turn when the design changes.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum XRef {
|
||||
/// A direction (component coordinates); its part square to z is taken.
|
||||
Dir(Vec3),
|
||||
/// A face: a plane's outward normal, a cylinder's way from the origin to its axis (another
|
||||
/// hole), any other face's way from the origin to the point of it nearest `pick` (component
|
||||
/// coordinates; it also tells the pieces of a split face apart, default the origin).
|
||||
Face {
|
||||
face: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pick: Option<Vec3>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Snap {
|
||||
/// The persistent face and edge names the snap refers to (its face, its end's, its x reference's;
|
||||
/// an edge origin's edge, face and corner edge).
|
||||
pub fn face_names(&self) -> Vec<&String> {
|
||||
let mut out = Vec::new();
|
||||
if let Snap::FaceCenter { face, x, .. } | Snap::CircleCenter { face, x, .. } = self {
|
||||
out.extend(face.iter());
|
||||
if let Some(XRef::Face { face, .. }) = x {
|
||||
out.push(face);
|
||||
}
|
||||
}
|
||||
if let Snap::CircleCenter { end: Some(end), .. } = self {
|
||||
out.push(end);
|
||||
}
|
||||
if let Snap::Edge { edge, face, corner, .. } = self {
|
||||
out.extend(edge.iter().chain(face.iter()).chain(corner.iter()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`Snap::face_names`], to rewrite them (a copied component's faces have new names).
|
||||
pub fn face_names_mut(&mut self) -> Vec<&mut String> {
|
||||
let mut out = Vec::new();
|
||||
match self {
|
||||
Snap::FaceCenter { face, x, .. } => {
|
||||
out.extend(face.iter_mut());
|
||||
if let Some(XRef::Face { face, .. }) = x {
|
||||
out.push(face);
|
||||
}
|
||||
}
|
||||
Snap::CircleCenter { face, end, x, .. } => {
|
||||
out.extend(face.iter_mut());
|
||||
out.extend(end.iter_mut());
|
||||
if let Some(XRef::Face { face, .. }) = x {
|
||||
out.push(face);
|
||||
}
|
||||
}
|
||||
Snap::Edge { edge, face, corner, .. } => out.extend(edge.iter_mut().chain(face.iter_mut()).chain(corner.iter_mut())),
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The direction a face or circle snap keeps as its frame's x (`XRef::Dir`), in the
|
||||
/// component's coordinates; `None` for a face reference or none.
|
||||
pub fn frame_x(&self) -> Option<Vec3> {
|
||||
match self {
|
||||
Snap::FaceCenter { x: Some(XRef::Dir(d)), .. } | Snap::CircleCenter { x: Some(XRef::Dir(d)), .. } => Some(*d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A face or circle snap given no x reference keeps `x` (the frame's x as it is now): each
|
||||
/// solve lays it onto the face, or square to the hole's axis, as the geometry is then, so a
|
||||
/// part on a face that tilts keeps its turn about z. Without it (designs made before) x
|
||||
/// follows from z alone, and turns through a right angle where z crosses 0.9 along the
|
||||
/// component's X. A snap with a reference keeps it.
|
||||
pub fn with_frame_x(mut self, x: Vec3) -> Snap {
|
||||
if let Snap::FaceCenter { x: slot @ None, .. } | Snap::CircleCenter { x: slot @ None, .. } = &mut self {
|
||||
*slot = Some(XRef::Dir(x));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether every point and direction in the snap is finite.
|
||||
pub fn is_finite(&self) -> bool {
|
||||
let x_ok = |x: &Option<XRef>| match x {
|
||||
Some(XRef::Dir(d)) => d.is_finite(),
|
||||
Some(XRef::Face { pick, .. }) => pick.is_none_or(|p| p.is_finite()),
|
||||
None => true,
|
||||
};
|
||||
match self {
|
||||
Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()),
|
||||
Snap::FaceCenter { pick, x, .. } | Snap::CircleCenter { pick, x, .. } => pick.is_finite() && x_ok(x),
|
||||
Snap::Point { point, z } => point.is_finite() && z.is_none_or(|z| z.is_finite()),
|
||||
Snap::Edge { pick, .. } => pick.is_finite(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A joint origin: a snap on an occurrence's component (occurrence 0 = the root design).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct JointOrigin {
|
||||
@@ -257,7 +379,7 @@ fn size_of(doc: &Document, st: &ModelState, s: &Size) -> Result<f64> {
|
||||
let v = match s {
|
||||
Size::Length(v) => *v,
|
||||
Size::Expr(e) => doc.eval(e, crate::expr::Kind::Length)?,
|
||||
Size::Circle(o) => circle_at(doc, st, o)?.1.radius,
|
||||
Size::Circle(o) => circle_at(doc, st, o)?.radius,
|
||||
};
|
||||
if v.is_finite() && v > 1e-9 && v <= MAX_SIZE {
|
||||
Ok(v)
|
||||
@@ -385,58 +507,85 @@ fn component_of(doc: &Document, occ: u64) -> Result<u64> {
|
||||
doc.occurrences.iter().find(|o| o.id == occ).map(|o| o.component).ok_or_else(|| DocError::Unknown(format!("occurrence {occ}")))
|
||||
}
|
||||
|
||||
/// Resolve a joint origin to a frame in its component's coordinates.
|
||||
/// A snap's pick point moved onto its named face (an error when the face is gone: a joint must
|
||||
/// not quietly attach elsewhere).
|
||||
fn named_pick(st: &ModelState, pick: Vec3, face: Option<&str>) -> Result<Vec3> {
|
||||
fn named_pick(doc: &Document, st: &ModelState, pick: Vec3, face: Option<&str>) -> Result<Vec3> {
|
||||
match face {
|
||||
Some(n) => crate::naming::point_on_face(st, n, pick)
|
||||
.map(|(q, _)| q)
|
||||
.ok_or_else(|| DocError::Invalid(format!("joint origin: the face `{n}` no longer exists (edit the joint to pick again)"))),
|
||||
Some(n) => face::named_face(doc, st, pick, n).map(|f| f.point),
|
||||
None => Ok(pick),
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre and outward normal of the planar face nearest `pick` (component coordinates; a
|
||||
/// body moved in from another component placed by its offset, as a named face is).
|
||||
fn nearest_plane(doc: &Document, st: &ModelState, o: &JointOrigin, pick: Vec3) -> Result<(Vec3, Vec3)> {
|
||||
let comp = component_of(doc, o.occurrence)?;
|
||||
let mut best: Option<(f64, Vec3, Vec3)> = None;
|
||||
for b in component_bodies(doc, st, comp) {
|
||||
let offset = doc.model_body_offset(b);
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
for f in b.body.faces(tol)? {
|
||||
let Some(n) = f.plane_normal else { continue };
|
||||
let (c, n) = match offset {
|
||||
Some(m) => (apply_point(m, f.centroid), apply_vector(m, n)),
|
||||
None => (f.centroid, n),
|
||||
};
|
||||
let d = (pick - c).dot(n).abs() + 1e-3 * (pick - c).len();
|
||||
if best.as_ref().is_none_or(|(bd, _, _)| d < *bd) {
|
||||
best = Some((d, c, n));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, c, n)| (c, n)).ok_or_else(|| DocError::Invalid("joint origin: no planar face there".into()))
|
||||
}
|
||||
|
||||
/// Resolve a joint origin to a frame in its component's coordinates.
|
||||
pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result<Mat> {
|
||||
let bad = || DocError::Invalid("joint origin: degenerate frame".into());
|
||||
match &o.snap {
|
||||
Snap::Frame { origin, z, x } => frame(*origin, *z, *x).ok_or_else(bad),
|
||||
Snap::Point { point, z } => frame(*point, z.unwrap_or(Vec3::Z), None).ok_or_else(bad),
|
||||
Snap::FaceCenter { pick, face } => {
|
||||
let pick = &named_pick(st, *pick, face.as_deref())?;
|
||||
let comp = component_of(doc, o.occurrence)?;
|
||||
let mut best: Option<(f64, Vec3, Vec3)> = None;
|
||||
for b in component_bodies(doc, st, comp) {
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
for f in b.body.faces(tol)? {
|
||||
let Some(n) = f.plane_normal else { continue };
|
||||
let d = (*pick - f.centroid).dot(n).abs() + 1e-3 * (*pick - f.centroid).len();
|
||||
if best.as_ref().is_none_or(|(bd, _, _)| d < *bd) {
|
||||
best = Some((d, f.centroid, n));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, c, n) = best.ok_or_else(|| DocError::Invalid("joint origin: no planar face there".into()))?;
|
||||
frame(c, n, None).ok_or_else(bad)
|
||||
Snap::FaceCenter { pick, face, x } => {
|
||||
// A named planar face gives its own centre and normal; another face (or none) the
|
||||
// planar face nearest the point.
|
||||
let named = match face.as_deref() {
|
||||
Some(n) => Some(face::named_face(doc, st, *pick, n)?),
|
||||
None => None,
|
||||
};
|
||||
let (c, n) = match named.as_ref().map(face::NamedFace::plane).transpose()?.flatten() {
|
||||
Some(cn) => cn,
|
||||
None => nearest_plane(doc, st, o, named.as_ref().map_or(*pick, |f| f.point))?,
|
||||
};
|
||||
frame(c, n, face::x_axis(doc, st, x.as_ref(), c)?).ok_or_else(bad)
|
||||
}
|
||||
Snap::CircleCenter { .. } => {
|
||||
let (pick, cyl) = circle_at(doc, st, o)?;
|
||||
let s = (pick - cyl.axis_point).dot(cyl.axis);
|
||||
let (at, dir) = if (s - cyl.start).abs() <= (cyl.end - s).abs() { (cyl.start, -1.0) } else { (cyl.end, 1.0) };
|
||||
frame(cyl.axis_point + cyl.axis * at, cyl.axis * dir, None).ok_or_else(bad)
|
||||
Snap::Edge { .. } => edge::edge_frame(doc, st, component_of(doc, o.occurrence)?, &o.snap),
|
||||
Snap::CircleCenter { x, .. } => {
|
||||
let r = round_of(doc, st, o)?;
|
||||
frame(r.centre, r.z, face::x_axis(doc, st, x.as_ref(), r.centre)?).ok_or_else(bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pick of a circle-centre snap (on its named face) and the cylindrical face there
|
||||
/// (component coordinates). Any other snap is refused.
|
||||
fn circle_at(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result<(Vec3, solidcraft_kernel::CylinderFace)> {
|
||||
let Snap::CircleCenter { pick, face } = &o.snap else { return Err(DocError::Invalid("pick a round face".into())) };
|
||||
let pick = named_pick(st, *pick, face.as_deref())?;
|
||||
/// A circle-centre snap's cylinder and the end it takes (component coordinates): through its
|
||||
/// named face when it names a cylinder, else the cylindrical face at its pick and the end nearer
|
||||
/// it, as older builds found them. Any other snap is refused.
|
||||
fn round_of(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result<face::Round> {
|
||||
let Snap::CircleCenter { pick, face, end, .. } = &o.snap else { return Err(DocError::Invalid("pick a round face".into())) };
|
||||
if let Some(n) = face.as_deref()
|
||||
&& let Some(r) = face::named_round(doc, st, *pick, n, end.as_deref())?
|
||||
{
|
||||
return Ok(r);
|
||||
}
|
||||
let pick = named_pick(doc, st, *pick, face.as_deref())?;
|
||||
let comp = component_of(doc, o.occurrence)?;
|
||||
// A body moved in from another component: looked at in its own coordinates, its cylinder
|
||||
// placed by its offset (as a named face is).
|
||||
let cyl = component_bodies(doc, st, comp)
|
||||
.into_iter()
|
||||
.filter_map(|b| solidcraft_kernel::cylinder_face_at(&b.body, pick))
|
||||
.filter_map(|b| match doc.model_body_offset(b) {
|
||||
Some(m) => solidcraft_kernel::cylinder_face_at(&b.body, apply_point(&mat_inverse(m)?, pick)).map(|c| face::placed(m, c)),
|
||||
None => solidcraft_kernel::cylinder_face_at(&b.body, pick),
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
let off = |c: &solidcraft_kernel::CylinderFace| {
|
||||
let v = pick - c.axis_point;
|
||||
@@ -445,7 +594,14 @@ fn circle_at(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result<(Vec3,
|
||||
off(a).total_cmp(&off(b))
|
||||
})
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: no cylindrical face there".into()))?;
|
||||
Ok((pick, cyl))
|
||||
let s = (pick - cyl.axis_point).dot(cyl.axis);
|
||||
let (at, dir) = if (s - cyl.start).abs() <= (cyl.end - s).abs() { (cyl.start, -1.0) } else { (cyl.end, 1.0) };
|
||||
Ok(face::Round { cylinder: cyl, centre: cyl.axis_point + cyl.axis * at, z: cyl.axis * dir })
|
||||
}
|
||||
|
||||
/// The cylindrical face of a circle-centre snap (component coordinates).
|
||||
fn circle_at(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result<solidcraft_kernel::CylinderFace> {
|
||||
round_of(doc, st, o).map(|r| r.cylinder)
|
||||
}
|
||||
|
||||
/// World transform of an occurrence (through its parents).
|
||||
@@ -1432,8 +1588,16 @@ impl Document {
|
||||
#[path = "joints_cam.rs"]
|
||||
pub mod cam;
|
||||
|
||||
#[path = "joints_edge.rs"]
|
||||
mod edge;
|
||||
pub use edge::edge_snap;
|
||||
|
||||
#[path = "joints_reach.rs"]
|
||||
mod reach;
|
||||
|
||||
#[path = "joints_face.rs"]
|
||||
mod face;
|
||||
pub use face::{end_faces, face_name_at, named_face_spot, pin_end, round_end_at};
|
||||
pub use reach::{REACH_TOL, Reach, Reached, reach};
|
||||
|
||||
/// The frame matrix for an explicit origin / z / x (public for commands).
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Joint origins on an edge of a planar face (`Snap::Edge`): the edge's middle, or a corner where
|
||||
//! it meets another edge of the face. The edge, the face and the corner's other edge are found
|
||||
//! again by name on every solve, so the origin follows the face as it grows, shrinks or moves:
|
||||
//! hardware set 40 mm in from a panel's corner stays 40 mm in from that corner at any size.
|
||||
//!
|
||||
//! The frame never depends on which way an edge's polyline runs: z is the face's outward normal;
|
||||
//! at the middle y points into the face, square to the edge, and x = y × z runs along it; at a
|
||||
//! corner x and y run along the two edges, away from the corner into the face, in the order that
|
||||
//! makes the frame right-handed.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{Snap, component_bodies, frame};
|
||||
use crate::{DocError, Document, Mat, ModelBody, ModelState, Result};
|
||||
|
||||
/// Points that close are one corner (as a share of the body's size, at least 1 µm).
|
||||
fn tol(b: &ModelBody) -> f64 {
|
||||
(b.body.size() * 2e-3).max(1e-3)
|
||||
}
|
||||
|
||||
/// The edge of `b` nearest `p`, and how far it is.
|
||||
fn nearest_edge(b: &ModelBody, p: Vec3) -> Option<(usize, f64)> {
|
||||
b.mesh()
|
||||
.edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| (i, e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min)))
|
||||
.filter(|(_, d)| d.is_finite())
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
}
|
||||
|
||||
/// Edge `i` of `b`: by its name (`None` when the body has no such edge), else the edge at `pick`.
|
||||
fn edge_index(b: &ModelBody, name: Option<&str>, pick: Vec3) -> Option<usize> {
|
||||
match name {
|
||||
Some(n) => crate::name_lookup::locate_edge(b, n, pick).ok().map(|h| h.edge),
|
||||
None => nearest_edge(b, pick).filter(|(_, d)| *d <= tol(b)).map(|(i, _)| i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The planar face beside edge `i` the origin sits on: the named one, else the larger planar face
|
||||
/// the edge bounds. Its index, outward normal and centroid.
|
||||
fn face_of(b: &ModelBody, i: usize, name: Option<&str>, pick: Vec3) -> Result<(usize, Vec3, Vec3)> {
|
||||
let m = b.mesh();
|
||||
let beside: Vec<usize> = m.edge_faces.get(i).map(|fs| fs.iter().map(|f| *f as usize).collect()).unwrap_or_default();
|
||||
let infos = b.body.faces(tol(b))?;
|
||||
let planar = |f: usize| infos.iter().find(|x| x.index == f).and_then(|x| x.plane_normal.map(|n| (x.index, n, x.centroid, x.area)));
|
||||
let chosen = match name {
|
||||
Some(n) => {
|
||||
let (f, _, _) = crate::naming::face_on_body(b, n, pick)
|
||||
.ok_or_else(|| DocError::Invalid(format!("joint origin: the face `{n}` no longer exists (edit the joint to pick again)")))?;
|
||||
if !beside.contains(&f) {
|
||||
return Err(DocError::Invalid(format!("joint origin: the face `{n}` no longer meets its edge (edit the joint to pick again)")));
|
||||
}
|
||||
planar(f)
|
||||
}
|
||||
None => beside.iter().filter_map(|f| planar(*f)).max_by(|a, b| a.3.total_cmp(&b.3)),
|
||||
};
|
||||
chosen.map(|(f, n, c, _)| (f, n, c)).ok_or_else(|| DocError::Invalid("joint origin: no planar face beside that edge".into()))
|
||||
}
|
||||
|
||||
/// Edge `i`'s middle (along its polyline) and the direction it runs there.
|
||||
fn middle(e: &[Vec3]) -> Option<(Vec3, Vec3)> {
|
||||
let total: f64 = e.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let mut acc = 0.0;
|
||||
for w in e.windows(2) {
|
||||
let l = w[0].dist(w[1]);
|
||||
if l > 0.0 && acc + l >= total / 2.0 {
|
||||
let t = (total / 2.0 - acc) / l;
|
||||
return Some((w[0] + (w[1] - w[0]) * t, (w[1] - w[0]) / l));
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// An edge's end at `p` (within `tol`) and the direction it leaves the end in.
|
||||
fn leaving(e: &[Vec3], p: Vec3, tol: f64) -> Option<Vec3> {
|
||||
let (first, last) = (*e.first()?, *e.last()?);
|
||||
let away = |q: Option<&Vec3>, from: Vec3| q.and_then(|q| (*q - from).normalized());
|
||||
if first.dist(p) <= tol {
|
||||
away(e.iter().find(|q| q.dist(first) > tol * 0.5), first)
|
||||
} else if last.dist(p) <= tol {
|
||||
away(e.iter().rev().find(|q| q.dist(last) > tol * 0.5), last)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The corner where edges `e` and `f` meet: the closest pair of their ends, when within `tol`.
|
||||
fn meeting(e: &[Vec3], f: &[Vec3], tol: f64) -> Option<Vec3> {
|
||||
let ends = |x: &[Vec3]| [x.first().copied(), x.last().copied()];
|
||||
ends(e)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|a| ends(f).into_iter().flatten().map(move |b| (a, a.dist(b))))
|
||||
.filter(|(_, d)| *d <= tol)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(a, _)| a)
|
||||
}
|
||||
|
||||
/// The frame of an edge snap in its component's coordinates.
|
||||
pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap) -> Result<Mat> {
|
||||
let Snap::Edge { pick, edge, face, corner } = snap else { return Err(DocError::Invalid("not an edge origin".into())) };
|
||||
let bad = || DocError::Invalid("joint origin: degenerate frame".into());
|
||||
let gone = |what: &str| DocError::Invalid(format!("joint origin: {what} no longer exists (edit the joint to pick again)"));
|
||||
let found = component_bodies(doc, st, comp).into_iter().find_map(|b| edge_index(b, edge.as_deref(), *pick).map(|i| (b, i)));
|
||||
let (b, i) = found.ok_or_else(|| match edge {
|
||||
Some(n) => gone(&format!("the edge `{n}`")),
|
||||
None => DocError::Invalid("joint origin: no edge there".into()),
|
||||
})?;
|
||||
let m = b.mesh();
|
||||
let e = m.edges.get(i).ok_or_else(bad)?;
|
||||
let (_, z, centroid) = face_of(b, i, face.as_deref(), *pick)?;
|
||||
let flat = |v: Vec3| (v - z * v.dot(z)).normalized();
|
||||
match corner {
|
||||
None => {
|
||||
let (mid, along) = middle(e).ok_or_else(bad)?;
|
||||
// Into the face: toward its centroid, square to the edge; a face whose centroid lies on
|
||||
// the edge's line (only a sliver) falls back to the side z × along gives.
|
||||
let to_c = centroid - mid;
|
||||
let y = flat(to_c - along * to_c.dot(along)).unwrap_or_else(|| z.cross(along));
|
||||
frame(mid, z, Some(y.cross(z))).ok_or_else(bad)
|
||||
}
|
||||
Some(other) => {
|
||||
let j = crate::name_lookup::locate_edge(b, other, *pick).map_err(|_| gone(&format!("the edge `{other}`")))?.edge;
|
||||
let f = m.edges.get(j).ok_or_else(bad)?;
|
||||
let t = tol(b);
|
||||
let at = meeting(e, f, t).ok_or_else(|| DocError::Invalid(format!("joint origin: its edges no longer meet at a corner (`{other}`)")))?;
|
||||
let (de, df) = (leaving(e, at, t).and_then(flat).ok_or_else(bad)?, leaving(f, at, t).and_then(flat).ok_or_else(bad)?);
|
||||
let x = if z.dot(de.cross(df)) >= 0.0 { de } else { df };
|
||||
frame(at, z, Some(x)).ok_or_else(bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge snap for an edge picked at `pick` on component `comp` (its own coordinates): on the
|
||||
/// planar face holding `on` (default: the larger planar face beside the edge), at the edge's
|
||||
/// middle or, with `corner`, at the end nearest it. The edge, the face and the corner's other
|
||||
/// edge are kept by name.
|
||||
pub fn edge_snap(doc: &Document, st: &ModelState, comp: u64, pick: Vec3, on: Option<Vec3>, corner: Option<Vec3>) -> Result<Snap> {
|
||||
let (b, i) = component_bodies(doc, st, comp)
|
||||
.into_iter()
|
||||
.filter_map(|b| nearest_edge(b, pick).filter(|(_, d)| *d <= tol(b)).map(|(i, _)| (b, i)))
|
||||
.next()
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: no edge there".into()))?;
|
||||
let names = crate::naming::edge_names(b);
|
||||
let face_names = crate::naming::face_names(b);
|
||||
let m = b.mesh();
|
||||
let beside: Vec<usize> = m.edge_faces.get(i).map(|fs| fs.iter().map(|f| *f as usize).collect()).unwrap_or_default();
|
||||
let face_index = match on {
|
||||
Some(p) => {
|
||||
let f = crate::naming::face_name_at_body(b, p)
|
||||
.and_then(|n| crate::naming::face_on_body(b, &n, p))
|
||||
.map(|(f, _, _)| f)
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: `on` is not on a face of that body".into()))?;
|
||||
if !beside.contains(&f) {
|
||||
return Err(DocError::Invalid("joint origin: `on` picks a face the edge doesn't bound".into()));
|
||||
}
|
||||
f
|
||||
}
|
||||
None => face_of(b, i, None, pick)?.0,
|
||||
};
|
||||
let face = face_names.get(face_index).cloned().filter(|n| !n.is_empty());
|
||||
let corner = match corner {
|
||||
None => None,
|
||||
Some(c) => {
|
||||
let e = m.edges.get(i).cloned().unwrap_or_default();
|
||||
let t = tol(b);
|
||||
let end = [e.first().copied(), e.last().copied()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min_by(|a, b| a.dist(c).total_cmp(&b.dist(c)))
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: that edge has no ends".into()))?;
|
||||
// The other edge of the face that ends there.
|
||||
let other = m
|
||||
.edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, _)| *j != i && m.edge_faces.get(*j).is_some_and(|fs| fs.contains(&(face_index as u32))))
|
||||
.find(|(_, f)| meeting(&[end], f, t).is_some())
|
||||
.map(|(j, _)| j)
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: no other edge of the face meets that end (a round edge has no corner)".into()))?;
|
||||
Some(
|
||||
names
|
||||
.get(other)
|
||||
.cloned()
|
||||
.filter(|n| !n.is_empty())
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: the corner's other edge has no name".into()))?,
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(Snap::Edge { pick, edge: names.get(i).cloned().filter(|n| !n.is_empty()), face, corner })
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Joint origins on named faces: a face found by its persistent name gives its own plane or
|
||||
//! cylinder (axis, radius, extent) directly, instead of searching again for the face nearest a
|
||||
//! point (on a thin plate the face nearest a point on a hole's wall is often the plate's face).
|
||||
//! A round origin's end is kept by the name of the face across the rim at that end, so it is the
|
||||
//! same end after the design changes; an x reference keeps the origin's turn about its axis.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_kernel::CylinderFace;
|
||||
|
||||
use super::{Snap, XRef, component_bodies};
|
||||
use crate::{DocError, Document, IDENTITY, Mat, ModelBody, ModelState, Result, apply_point, apply_vector, mat_inverse};
|
||||
|
||||
/// Rim points sampled to find the face across a cylinder's rim.
|
||||
const RIM_SAMPLES: usize = 16;
|
||||
|
||||
/// A face found by its persistent name: its body, its index there, the point of it nearest the
|
||||
/// pick (the component's coordinates) and the body's offset (a body moved in from another
|
||||
/// component keeps its geometry where it was made; the offset places it).
|
||||
pub(super) struct NamedFace<'a> {
|
||||
pub body: &'a ModelBody,
|
||||
pub face: usize,
|
||||
pub point: Vec3,
|
||||
pub offset: Mat,
|
||||
}
|
||||
|
||||
fn gone(name: &str) -> DocError {
|
||||
DocError::Invalid(format!("joint origin: the face `{name}` no longer exists (edit the joint to pick again)"))
|
||||
}
|
||||
|
||||
/// The face called `name` nearest `pick` (an error when it is gone: a joint must not quietly
|
||||
/// attach elsewhere).
|
||||
pub(super) fn named_face<'a>(doc: &Document, st: &'a ModelState, pick: Vec3, name: &str) -> Result<NamedFace<'a>> {
|
||||
let hit = crate::name_lookup::locate_face(st, name, pick).ok_or_else(|| gone(name))?;
|
||||
let body = st.bodies.get(hit.body).ok_or_else(|| gone(name))?;
|
||||
let Some(offset) = doc.model_body_offset(body).copied() else {
|
||||
return Ok(NamedFace { body, face: hit.face, point: hit.point, offset: IDENTITY });
|
||||
};
|
||||
let inv = mat_inverse(&offset).ok_or_else(|| gone(name))?;
|
||||
let (face, at, _) = crate::naming::face_on_body(body, name, apply_point(&inv, pick)).ok_or_else(|| gone(name))?;
|
||||
Ok(NamedFace { body, face, point: apply_point(&offset, at), offset })
|
||||
}
|
||||
|
||||
impl NamedFace<'_> {
|
||||
/// A point of the component's coordinates in the body's own.
|
||||
fn local(&self, p: Vec3) -> Vec3 {
|
||||
if self.offset == IDENTITY {
|
||||
return p;
|
||||
}
|
||||
mat_inverse(&self.offset).map_or(p, |inv| apply_point(&inv, p))
|
||||
}
|
||||
|
||||
/// The face as a cylinder, in the body's own coordinates (`None`: not a cylinder).
|
||||
fn own_cylinder(&self) -> Option<CylinderFace> {
|
||||
solidcraft_kernel::cylinder_face(&self.body.body, &self.body.mesh(), self.face)
|
||||
}
|
||||
|
||||
/// The face as a cylinder, in the component's coordinates.
|
||||
pub fn cylinder(&self) -> Option<CylinderFace> {
|
||||
self.own_cylinder().map(|c| placed(&self.offset, c))
|
||||
}
|
||||
|
||||
/// The face's centre and outward normal (the component's coordinates), when it is planar.
|
||||
pub fn plane(&self) -> Result<Option<(Vec3, Vec3)>> {
|
||||
let tol = (self.body.body.size() * 2e-3).max(1e-3);
|
||||
let faces = self.body.body.faces(tol)?;
|
||||
Ok(faces.get(self.face).and_then(|f| f.plane_normal.map(|n| (apply_point(&self.offset, f.centroid), apply_vector(&self.offset, n)))))
|
||||
}
|
||||
}
|
||||
|
||||
/// A cylinder of a body's own coordinates placed by the body's (rigid) offset.
|
||||
pub(super) fn placed(m: &Mat, c: CylinderFace) -> CylinderFace {
|
||||
CylinderFace { axis_point: apply_point(m, c.axis_point), axis: apply_vector(m, c.axis), ..c }
|
||||
}
|
||||
|
||||
/// The face across the rim at the `at` end (along the axis) of cylinder `c`, face `face` of `b`
|
||||
/// (the body's own coordinates): of the body's other faces, the one nearest the rim's points.
|
||||
fn rim_neighbour(b: &ModelBody, face: usize, c: &CylinderFace, at: f64) -> Option<usize> {
|
||||
let mesh = b.mesh();
|
||||
let slack = (c.end - c.start).abs() * 1e-3 + 1e-9;
|
||||
let mut rim: Vec<Vec3> = Vec::new();
|
||||
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if *f as usize != face {
|
||||
continue;
|
||||
}
|
||||
for k in t {
|
||||
if let Some(q) = mesh.positions.get(*k as usize)
|
||||
&& ((*q - c.axis_point).dot(c.axis) - at).abs() <= slack
|
||||
&& !rim.iter().any(|r| r.dist(*q) <= slack)
|
||||
{
|
||||
rim.push(*q);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Evenly through the rim's points, at most RIM_SAMPLES of them.
|
||||
let step = rim.len().div_ceil(RIM_SAMPLES).max(1);
|
||||
let rim: Vec<Vec3> = rim.into_iter().step_by(step).collect();
|
||||
if rim.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut near: BTreeMap<usize, Vec<f64>> = BTreeMap::new();
|
||||
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
let f = *f as usize;
|
||||
if f == face {
|
||||
continue;
|
||||
}
|
||||
let Some([p, q, r]) = mesh.tri(t) else { continue };
|
||||
let slot = near.entry(f).or_insert_with(|| vec![f64::INFINITY; rim.len()]);
|
||||
for (d, x) in slot.iter_mut().zip(&rim) {
|
||||
*d = d.min(crate::naming::closest(*x, p, q, r).dist(*x));
|
||||
}
|
||||
}
|
||||
// The face that meets the whole rim (the other half of a cylinder split in two meets only
|
||||
// the rim's points at its seams).
|
||||
near.into_iter().map(|(f, d)| (d.iter().sum::<f64>(), f)).min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, f)| f)
|
||||
}
|
||||
|
||||
/// The ends of a cylinder: (place along the axis, which way out of the face).
|
||||
fn ends(c: &CylinderFace) -> [(f64, f64); 2] {
|
||||
[(c.start, -1.0), (c.end, 1.0)]
|
||||
}
|
||||
|
||||
/// The end of `c` nearer `pick` along the axis. On a tie, the end older builds took: the start
|
||||
/// of the axis as the kernel turns it (toward +x, else +y, else +z), whichever way the face's
|
||||
/// own surface runs.
|
||||
fn nearer_end(c: &CylinderFace, pick: Vec3) -> (f64, f64) {
|
||||
let s = (pick - c.axis_point).dot(c.axis);
|
||||
let [start, end] = ends(c);
|
||||
let (to_start, to_end) = ((s - c.start).abs(), (c.end - s).abs());
|
||||
let reversed = c.axis.x + c.axis.y * 1e-3 + c.axis.z * 1e-6 < 0.0;
|
||||
if to_start < to_end || (to_start == to_end && !reversed) { start } else { end }
|
||||
}
|
||||
|
||||
/// The end a named round face takes without an `end` (a design saved before origins named
|
||||
/// it): the one nearer the pick (moved onto the face) along the axis, decided as those builds
|
||||
/// decided it, by the kernel's reading of the cylinder at the pick, so a pick midway along the
|
||||
/// wall takes the same end as it did then. `own` is the face's cylinder (the body's own
|
||||
/// coordinates).
|
||||
fn end_by_pick(nf: &NamedFace, own: &CylinderFace) -> (f64, f64) {
|
||||
let pick = nf.local(nf.point);
|
||||
let [start, end] = ends(own);
|
||||
match solidcraft_kernel::cylinder_face_at(&nf.body.body, pick).filter(|k| k.axis.dot(own.axis).abs() > 0.5) {
|
||||
Some(k) => {
|
||||
let s = (pick - k.axis_point).dot(k.axis);
|
||||
let out = if (s - k.start).abs() <= (k.end - s).abs() { -k.axis } else { k.axis };
|
||||
if out.dot(own.axis) < 0.0 { start } else { end }
|
||||
}
|
||||
None => nearer_end(own, pick),
|
||||
}
|
||||
}
|
||||
|
||||
/// A round joint origin worked out: its cylinder, and the centre and outward axis of the end
|
||||
/// it takes (the component's coordinates).
|
||||
pub(super) struct Round {
|
||||
pub cylinder: CylinderFace,
|
||||
pub centre: Vec3,
|
||||
pub z: Vec3,
|
||||
}
|
||||
|
||||
impl Round {
|
||||
fn at(c: CylinderFace, (at, dir): (f64, f64)) -> Round {
|
||||
Round { cylinder: c, centre: c.axis_point + c.axis * at, z: c.axis * dir }
|
||||
}
|
||||
}
|
||||
|
||||
/// A circle-centre snap's cylinder found through its named face (`None`: the snap names no
|
||||
/// face, or the face is not a cylinder any more; the caller looks by point, as older builds
|
||||
/// did). The end is the one whose rim meets the face called `end`, else the one nearer `pick`.
|
||||
pub(super) fn named_round(doc: &Document, st: &ModelState, pick: Vec3, face: &str, end: Option<&str>) -> Result<Option<Round>> {
|
||||
let nf = named_face(doc, st, pick, face)?;
|
||||
let Some(own) = nf.own_cylinder() else { return Ok(None) };
|
||||
let chosen = end
|
||||
.and_then(|name| {
|
||||
let hits: Vec<(f64, f64)> = ends(&own)
|
||||
.into_iter()
|
||||
.filter(|(at, _)| rim_neighbour(nf.body, nf.face, &own, *at).is_some_and(|f| crate::name_lookup::face_has_name(nf.body, f, name)))
|
||||
.collect();
|
||||
match hits[..] {
|
||||
[one] => Some(one),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| end_by_pick(&nf, &own));
|
||||
Ok(Some(Round::at(placed(&nf.offset, own), chosen)))
|
||||
}
|
||||
|
||||
/// The x axis an x reference gives a frame at `o` with axis `z` (`None`: no reference).
|
||||
pub(super) fn x_axis(doc: &Document, st: &ModelState, x: Option<&XRef>, o: Vec3) -> Result<Option<Vec3>> {
|
||||
Ok(match x {
|
||||
None => None,
|
||||
Some(XRef::Dir(d)) => Some(*d),
|
||||
Some(XRef::Face { face, pick }) => {
|
||||
let nf = named_face(doc, st, pick.unwrap_or(o), face)?;
|
||||
if let Some((_, n)) = nf.plane()? {
|
||||
Some(n)
|
||||
} else if let Some(c) = nf.cylinder() {
|
||||
let v = c.axis_point - o;
|
||||
Some(v - c.axis * v.dot(c.axis))
|
||||
} else {
|
||||
Some(nf.point - o)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The persistent name of the face of a component's bodies at `pick` (the component's
|
||||
/// coordinates), measured to each face's triangles and, among the faces as near as the mesh
|
||||
/// lets them be told apart, to the face's own surface (a point on a thin plate's hole wall lies
|
||||
/// as near the facets of the plate's face round the rim). `round` prefers a cylinder on a tie
|
||||
/// (a pick on a hole's rim names its wall). `None`: no named face there.
|
||||
pub fn face_name_at(doc: &Document, st: &ModelState, component: u64, pick: Vec3, round: bool) -> Option<String> {
|
||||
let mut best: Option<(f64, &ModelBody, usize)> = None;
|
||||
for b in component_bodies(doc, st, component) {
|
||||
if b.body.is_mesh() {
|
||||
continue;
|
||||
}
|
||||
let offset = doc.model_body_offset(b).copied().unwrap_or(IDENTITY);
|
||||
let p = apply_point(&mat_inverse(&offset)?, pick);
|
||||
let mesh = b.mesh();
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3).max(b.display_tol());
|
||||
let mut near: BTreeMap<usize, f64> = BTreeMap::new();
|
||||
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
let Some([x, y, z]) = mesh.tri(t) else { continue };
|
||||
let d = crate::naming::closest(p, x, y, z).dist(p);
|
||||
near.entry(*f as usize).and_modify(|n| *n = n.min(d)).or_insert(d);
|
||||
}
|
||||
let nearest = near.values().copied().fold(f64::INFINITY, f64::min);
|
||||
if nearest > tol * 5.0 {
|
||||
continue;
|
||||
}
|
||||
for (f, d) in near.into_iter().filter(|(_, d)| *d <= nearest + tol) {
|
||||
let (off, cylinder) = match solidcraft_kernel::face_surface(&b.body, f) {
|
||||
Some(solidcraft_kernel::FaceSurface::Plane { normal, point }) => (d.min((p - point).dot(normal).abs()), false),
|
||||
Some(solidcraft_kernel::FaceSurface::Cylinder { .. }) => {
|
||||
(solidcraft_kernel::cylinder_face(&b.body, &mesh, f).map_or(d, |c| d.min(c.off(p))), true)
|
||||
}
|
||||
_ => (d, false),
|
||||
};
|
||||
let score = if round && cylinder { off - tol } else { off };
|
||||
if best.as_ref().is_none_or(|(s, ..)| score < *s) {
|
||||
best = Some((score, b, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, b, f) = best?;
|
||||
crate::naming::face_names(b).get(f).filter(|n| !n.is_empty()).cloned()
|
||||
}
|
||||
|
||||
/// A circle-centre snap for the round end of a component's cylindrical face centred at
|
||||
/// `centre` (the component's coordinates; a circle given by its centre, where no face lies),
|
||||
/// with the face's name and its end's: `None` when no named cylinder ends there.
|
||||
pub fn round_end_at(doc: &Document, st: &ModelState, component: u64, centre: Vec3) -> Option<Snap> {
|
||||
let mut best: Option<(f64, &ModelBody, usize, CylinderFace, f64)> = None;
|
||||
for b in component_bodies(doc, st, component) {
|
||||
if b.body.is_mesh() {
|
||||
continue;
|
||||
}
|
||||
let offset = doc.model_body_offset(b).copied().unwrap_or(IDENTITY);
|
||||
let p = apply_point(&mat_inverse(&offset)?, centre);
|
||||
let mesh = b.mesh();
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||||
for f in 0..b.body.face_count() {
|
||||
let Some(c) = solidcraft_kernel::cylinder_face(&b.body, &mesh, f) else { continue };
|
||||
let v = p - c.axis_point;
|
||||
let along = v.dot(c.axis);
|
||||
for (at, _) in ends(&c) {
|
||||
let off = (v - c.axis * along).len() + (along - at).abs();
|
||||
if off <= tol && best.as_ref().is_none_or(|(o, ..)| off < *o) {
|
||||
best = Some((off, b, f, c, at));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, b, f, c, at) = best?;
|
||||
let names = crate::naming::face_names(b);
|
||||
let face = names.get(f).filter(|n| !n.is_empty()).cloned()?;
|
||||
let end = rim_neighbour(b, f, &c, at).and_then(|n| names.get(n)).filter(|n| !n.is_empty()).cloned();
|
||||
Some(Snap::CircleCenter { pick: centre, face: Some(face), end, x: None })
|
||||
}
|
||||
|
||||
/// Name the end a named circle-centre snap takes now (the face across the rim there), so it
|
||||
/// keeps that end when the design changes. Other snaps, and one whose end is named already, are
|
||||
/// left as they are.
|
||||
pub fn pin_end(doc: &Document, st: &ModelState, snap: &mut Snap) {
|
||||
let Snap::CircleCenter { pick, face: Some(face), end: end @ None, .. } = snap else { return };
|
||||
let Ok(nf) = named_face(doc, st, *pick, face) else { return };
|
||||
let Some(own) = nf.own_cylinder() else { return };
|
||||
let (at, _) = end_by_pick(&nf, &own);
|
||||
*end = rim_neighbour(nf.body, nf.face, &own, at).and_then(|n| crate::naming::face_names(nf.body).get(n).cloned()).filter(|n| !n.is_empty());
|
||||
}
|
||||
|
||||
/// The names of the faces across the rims at the two ends of a named circle-centre snap's
|
||||
/// cylinder (what its `end` can be); empty when the snap names no cylinder.
|
||||
pub fn end_faces(doc: &Document, st: &ModelState, snap: &Snap) -> Vec<String> {
|
||||
let Snap::CircleCenter { pick, face: Some(face), .. } = snap else { return Vec::new() };
|
||||
let Ok(nf) = named_face(doc, st, *pick, face) else { return Vec::new() };
|
||||
let Some(own) = nf.own_cylinder() else { return Vec::new() };
|
||||
let names = crate::naming::face_names(nf.body);
|
||||
ends(&own)
|
||||
.iter()
|
||||
.filter_map(|(at, _)| rim_neighbour(nf.body, nf.face, &own, *at).and_then(|n| names.get(n).cloned()))
|
||||
.filter(|n| !n.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The face `model.faces` calls `name` (on the body called `body`, or any body): the component
|
||||
/// it belongs to and a point on it in that component's coordinates, for a joint origin given by
|
||||
/// a face's name. `Err` says why there is no one such face.
|
||||
pub fn named_face_spot(doc: &Document, st: &ModelState, body: Option<&str>, name: &str) -> std::result::Result<(u64, Vec3), String> {
|
||||
let hit = crate::name_lookup::locate_one(st, body, name)?;
|
||||
let b = st.bodies.get(hit.body).ok_or_else(|| format!("no face is named `{name}`"))?;
|
||||
let spot = crate::naming::face_spot(b, hit.face).ok_or_else(|| format!("face `{name}` has no surface to pick"))?;
|
||||
let offset = doc.model_body_offset(b).copied().unwrap_or(IDENTITY);
|
||||
Ok((doc.model_body_component(b), apply_point(&offset, spot.point)))
|
||||
}
|
||||
@@ -17,6 +17,7 @@ pub use clipboard::{clone_expression, remap_topology};
|
||||
pub mod config;
|
||||
pub mod construct;
|
||||
pub mod display_quality;
|
||||
pub mod display_units;
|
||||
mod document;
|
||||
mod eval;
|
||||
pub mod expr;
|
||||
|
||||
@@ -370,7 +370,7 @@ mod tests {
|
||||
sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap();
|
||||
let report = solve(&mut sketch);
|
||||
let profiles = find_profiles(&sketch);
|
||||
let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles };
|
||||
let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles, ends: Default::default() };
|
||||
assert_eq!(ss.profiles.len(), 1);
|
||||
let indices = vec![0; 600];
|
||||
let selection = ProfileSel::Indices { indices: indices.clone() };
|
||||
|
||||
@@ -45,6 +45,64 @@ fn volume(s: &Session, body: &str) -> f64 {
|
||||
s.model.state().body(body).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// #2059: tabs exactly as wide as their supporting ribs share the ribs' side
|
||||
/// planes. The base's remote conical chamfer must not prevent their join.
|
||||
#[test]
|
||||
fn eighteen_tabs_join_the_ribs_of_a_chamfered_base() {
|
||||
let s = replay(include_str!("../../../examples/regressions/tabs_on_chamfered_ribbed_base.json"));
|
||||
let check = |s: &Session| {
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let state = s.model.state();
|
||||
let body = &state.body("Base").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||||
let mesh = body.tessellate(1e-4).unwrap();
|
||||
// R40 x 4 disc less its 1 x 1 chamfer, six 32 x 2 ribs
|
||||
// standing 4 above it, and eighteen 1 x 2 tabs another 8 higher.
|
||||
let expected = std::f64::consts::PI * (6400.0 - 40.0 + 1.0 / 3.0) + 1536.0 + 288.0;
|
||||
let got = mesh.measure().volume;
|
||||
assert!((got - expected).abs() < 0.1, "{got} vs {expected}");
|
||||
println!("#2059 volume {got:.6} / {expected:.6} mm³; one valid shell, eighteen tabs");
|
||||
for k in 0..6 {
|
||||
let angle = k as f64 * std::f64::consts::TAU / 6.0;
|
||||
let p = |x: f64, y: f64, z| solidcraft_geom::Vec3::new(x * angle.cos() - y * angle.sin(), x * angle.sin() + y * angle.cos(), z);
|
||||
for x in [15.5, 25.5, 35.5] {
|
||||
assert!(mesh.contains(p(x, 0.0, 12.0)), "tab {k} at {x}");
|
||||
assert!(!mesh.contains(p(x, 1.2, 12.0)), "tab {k} grew sideways at {x}");
|
||||
}
|
||||
assert!(mesh.contains(p(20.0, 0.0, 6.0)), "rib {k}");
|
||||
assert!(mesh.contains(p(39.5, 0.0, 2.0)), "rim {k}");
|
||||
assert!(!mesh.contains(p(39.75, 0.0, 3.75)), "chamfer {k}");
|
||||
}
|
||||
assert!((mesh.bounds().max.z - 16.0).abs() < 1e-8);
|
||||
};
|
||||
check(&s);
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check(&reopened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buried_join_end_ignores_bodies_in_other_components() {
|
||||
for own_body in [false, true] {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, ¶ms).unwrap();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 8, "body_name": "Other"}));
|
||||
run(&mut s, "component.create", json!({"name": "Child"}));
|
||||
if own_body {
|
||||
run(&mut s, "solid.box", json!({"corner": [50, 0, 0], "length": 10, "width": 10, "height": 8, "body_name": "Child"}));
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 2], "p1": [4, 4]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 16, "operation": "join", "body_name": "Tab"}));
|
||||
let got = volume(&s, if own_body { "Child" } else { "Tab" });
|
||||
let expected = 64.0 + if own_body { 800.0 } else { 0.0 };
|
||||
assert!((got - expected).abs() < 1e-6, "{got}");
|
||||
assert!((volume(&s, "Other") - 800.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to
|
||||
/// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)).
|
||||
fn slab_pocket_loft() -> (f64, f64) {
|
||||
@@ -475,3 +533,372 @@ fn box_intersect_splits_the_end_off_a_raked_wedge_after_its_end_rounds() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1853: half a slot drawn on a plate's top face, cut along its centreline, revolved about a
|
||||
/// construction line on that centreline as a cut. Half a turn into the plate and a full turn
|
||||
/// both failed with "no result": the revolve came out wrong where its arcs end on the axis (a
|
||||
/// fifth too much or too little), and the cut could not intersect it there. Both now take the
|
||||
/// half of the capsule below the face.
|
||||
#[test]
|
||||
fn revolve_cut_of_a_half_slot_about_its_centreline_in_the_face() {
|
||||
let pi = std::f64::consts::PI;
|
||||
let inside = (pi * 9.0 * 14.0 + 4.0 / 3.0 * pi * 27.0) / 2.0;
|
||||
for angle in ["-180 deg", "360 deg"] {
|
||||
let mut s = Session::default();
|
||||
let steps = [
|
||||
("solid.box", json!({"corner": [0, 0, 0], "length": 40, "width": 20, "height": 10, "body_name": "Plate"})),
|
||||
("sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Half"})),
|
||||
("sketch.line", json!({"points": [[10, 10], [30, 10]]})),
|
||||
("sketch.line", json!({"points": [[27, 13], [13, 13]]})),
|
||||
("sketch.arc.center_point", json!({"center": [13, 10], "start": [13, 13], "end": [10, 10]})),
|
||||
("sketch.arc.center_point", json!({"center": [27, 10], "start": [30, 10], "end": [27, 13]})),
|
||||
("sketch.line", json!({"points": [[5, 10], [35, 10]], "construction": true, "ids": ["axis"]})),
|
||||
("sketch.finish", json!({})),
|
||||
];
|
||||
for (command, params) in steps {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"));
|
||||
}
|
||||
let whole = volume(&s, "Plate");
|
||||
s.execute("solid.revolve", &json!({"sketch": "Half", "axis": "axis", "angle": angle, "operation": "cut"}))
|
||||
.unwrap_or_else(|e| panic!("revolve {angle}: {e}"));
|
||||
let bad = s.model.state().body("Plate").map(|b| b.body.validity()).unwrap_or_default();
|
||||
assert!(bad.is_empty(), "{angle}: {bad:?}");
|
||||
let removed = whole - volume(&s, "Plate");
|
||||
println!("#1853 revolve {angle}: removed {removed:.6} mm³ of the {inside:.6} below the face");
|
||||
assert!((removed - inside).abs() < 1e-3 * inside, "{angle}: removed {removed} instead of {inside}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2005: changing a circular sweep from a separate body to a half-turn cut must create the
|
||||
/// internal channel, rather than failing the Boolean on coincident end caps.
|
||||
#[test]
|
||||
fn closed_sweep_redefines_to_a_half_turn_torus_cut() {
|
||||
let s = replay(include_str!("../../../examples/regressions/closed_sweep_torus_cut.json"));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let body = &s.model.state().bodies[0].body;
|
||||
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||||
let mesh = body.tessellate(0.02).unwrap();
|
||||
let expected = (std::f64::consts::PI * 64.0 - 64.0) * std::f64::consts::TAU * 30.0;
|
||||
assert!((mesh.measure().volume - expected).abs() < expected * 0.015);
|
||||
for (point, material) in [(solidcraft_geom::Vec3::new(30.0, 0.0, 0.0), false), (solidcraft_geom::Vec3::new(30.0, 0.0, 7.0), true)] {
|
||||
assert_eq!(mesh.contains(point), material, "material at {point:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_extrusion_profiles_preserve_material_and_body_identity() {
|
||||
for operation in ["join", "cut", "new"] {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20, "body_name": "Stock"})).unwrap();
|
||||
s.execute("sketch.create", &json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})).unwrap();
|
||||
for i in 0..6 {
|
||||
for j in 0..4 {
|
||||
let (x, y) = (2 + i * 6, 2 + j * 7);
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [x, y], "p1": [x + 4, y + 4]})).unwrap();
|
||||
}
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
let distance = if operation == "cut" { -5 } else { 5 };
|
||||
let before = s.revision;
|
||||
s.execute("solid.extrude", &json!({"distance": distance, "operation": operation})).unwrap();
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), if operation == "new" { 25 } else { 1 });
|
||||
let expected = 24_000.0
|
||||
+ match operation {
|
||||
"join" => 1920.0,
|
||||
"cut" => -1920.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
assert!((volume(&s, "Stock") - expected).abs() < 1e-6);
|
||||
let stock = state.body("Stock").unwrap();
|
||||
for (point, present) in [([3.0, 3.0, 22.0], operation == "join"), ([3.0, 3.0, 18.0], operation != "cut"), ([7.0, 3.0, 18.0], true)] {
|
||||
assert_eq!(stock.mesh().contains(solidcraft_geom::Vec3::from(point)), present, "{operation}: {point:?}");
|
||||
}
|
||||
assert!(stock.body.validity().is_empty());
|
||||
assert!(s.revision > before);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((volume(&s, "Stock") - 24_000.0).abs() < 1e-6);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!((volume(&s, "Stock") - expected).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_circular_extrusion_profiles_cut_a_curved_target() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.cylinder", &json!({"radius": 20, "height": 20, "body_name": "Stock"})).unwrap();
|
||||
s.execute("sketch.create", &json!({"plane": {"origin": [0, 0, -1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})).unwrap();
|
||||
for x in [-9, -3, 3, 9] {
|
||||
for y in [-9, -3, 3, 9] {
|
||||
s.execute("sketch.circle.center", &json!({"center": [x, y], "radius": 1})).unwrap();
|
||||
}
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
s.execute("solid.extrude", &json!({"distance": 22, "operation": "cut", "targets": ["Stock"]})).unwrap();
|
||||
// The rebuilt caps mesh more finely than the original cylinder. Compare with the
|
||||
// analytic material volume, rather than subtracting two differently faceted meshes.
|
||||
let expected = (400.0 - 16.0) * std::f64::consts::PI * 20.0;
|
||||
let actual = volume(&s, "Stock");
|
||||
assert!((actual - expected).abs() < expected * 1e-5, "{actual} vs {expected}");
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), 1);
|
||||
let stock = state.body("Stock").unwrap();
|
||||
assert!(stock.body.validity().is_empty());
|
||||
let mesh = stock.mesh();
|
||||
assert!(mesh.contains(solidcraft_geom::Vec3::new(0.0, 0.0, 10.0)));
|
||||
for x in [-9.0, -3.0, 3.0, 9.0] {
|
||||
for y in [-9.0, -3.0, 3.0, 9.0] {
|
||||
assert!(!mesh.contains(solidcraft_geom::Vec3::new(x, y, 10.0)), "hole at {x}, {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coplanar_body_faces_extrude_together_and_replay_their_links() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":64,"width":64,"height":20,"body_name":"Plate"})).unwrap();
|
||||
s.execute("sketch.create", &json!({"plane":{"origin":[0,0,20],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
|
||||
let mut faces = Vec::new();
|
||||
for i in 0..8 {
|
||||
for j in 0..8 {
|
||||
let (x, y) = (2 + i * 7, 2 + j * 7);
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0":[x,y],"p1":[x+4,y+4]})).unwrap();
|
||||
faces.push(json!([x + 2, y + 2, 26]));
|
||||
}
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
s.execute("solid.extrude", &json!({"distance":6,"operation":"join"})).unwrap();
|
||||
let (features, undo) = (s.doc.features.len(), s.undo.len());
|
||||
s.execute("solid.extrude", &json!({"faces":faces,"distance":-7,"operation":"cut"})).unwrap();
|
||||
assert_eq!(s.doc.features.len(), features + 2, "one linked sketch and one extrusion");
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let actual = volume(&s, "Plate");
|
||||
let state = s.model.state();
|
||||
let sketch = state.sketch(4).unwrap();
|
||||
assert!((actual - 80_896.0).abs() < 1e-5, "volume {actual}; plane {:?}; profiles {}", sketch.plane, sketch.profiles.len());
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let document = serde_json::from_value(serde_json::to_value(s.doc.as_ref()).unwrap()).unwrap();
|
||||
let mut reopened = Session { doc: std::sync::Arc::new(document), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!((volume(&reopened, "Plate") - 80_896.0).abs() < 1e-5);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.features.len(), features);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!((volume(&s, "Plate") - 80_896.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_face_groups_are_atomic() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
let before = serde_json::to_value(s.doc.as_ref()).unwrap();
|
||||
let (revision, undo) = (s.revision, s.undo.len());
|
||||
for faces in [
|
||||
json!([]),
|
||||
json!([[5, 5, 10], [5, 5, 0]]),
|
||||
json!([[5, 5, 10], [5, 0, 5]]),
|
||||
json!([[5, 5, 10], [1000, 1000, 1000]]),
|
||||
json!(vec![[5, 5, 10]; 1025]),
|
||||
] {
|
||||
assert!(s.execute("solid.extrude", &json!({"faces":faces,"distance":5,"operation":"join"})).is_err());
|
||||
assert_eq!(serde_json::to_value(s.doc.as_ref()).unwrap(), before);
|
||||
assert_eq!((s.revision, s.undo.len()), (revision, undo));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_rotated_face_extrusions_preserve_other_operations_and_bodies() {
|
||||
use solidcraft_geom::Vec3;
|
||||
let mut source = Session::default();
|
||||
source.execute("solid.box", &json!({"corner":[-16,-16,-3],"length":32,"width":32,"height":1,"body_name":"Other"})).unwrap();
|
||||
source.execute("solid.cylinder", &json!({"radius":14,"height":8,"body_name":"Stock"})).unwrap();
|
||||
let mut outlines = Vec::new();
|
||||
let mut centers = Vec::new();
|
||||
for i in 0..12 {
|
||||
let angle = i as f64 * std::f64::consts::TAU / 12.;
|
||||
let (c, s) = (angle.cos(), angle.sin());
|
||||
let outline: Vec<_> =
|
||||
[(-2., -2.), (2., -2.), (2., 2.), (-2., 2.)].into_iter().map(|(x, y)| [10.7 * c + x * c - y * s, 10.7 * s + x * s + y * c]).collect();
|
||||
outlines.push(outline);
|
||||
centers.push([10.7 * c, 10.7 * s, 12.]);
|
||||
}
|
||||
let draw = |session: &mut Session, height: f64| {
|
||||
session.execute("sketch.create", &json!({"plane":{"origin":[0,0,height],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
|
||||
for outline in &outlines {
|
||||
session.execute("sketch.line", &json!({"points":outline,"closed":true,"infer":false})).unwrap();
|
||||
}
|
||||
session.execute("sketch.finish", &json!({})).unwrap();
|
||||
};
|
||||
draw(&mut source, 8.);
|
||||
source.execute("solid.extrude", &json!({"distance":4,"operation":"join"})).unwrap();
|
||||
// An original public-safe STEP example, with rotated full-face seams and a
|
||||
// curved target. No vendor/user design is part of this regression.
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-face-extrusion-{}.step", std::process::id()));
|
||||
source.execute("file.export", &json!({"path":path.to_string_lossy()})).unwrap();
|
||||
let mut imported = Session::default();
|
||||
imported.execute("doc.open", &json!({"path":path.to_string_lossy()})).unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
let original = imported.model.state();
|
||||
let other = original.body("Other").unwrap().body.clone();
|
||||
for (operation, distance) in [("join", 2.), ("cut", -1.), ("new", 2.), ("intersect", -1.)] {
|
||||
let mut grouped = imported.scratch();
|
||||
grouped.execute("solid.extrude", &json!({"faces":centers,"distance":distance,"operation":operation})).unwrap();
|
||||
let mut reference = imported.scratch();
|
||||
draw(&mut reference, 12.);
|
||||
if matches!(operation, "join" | "cut") {
|
||||
// Individual tools retain the original Boolean and participant path.
|
||||
for index in 0..12 {
|
||||
reference
|
||||
.execute("solid.extrude", &json!({"sketch":"Sketch1","profiles":[index],"distance":distance,"operation":operation}))
|
||||
.unwrap();
|
||||
}
|
||||
} else {
|
||||
reference.execute("solid.extrude", &json!({"distance":distance,"operation":operation})).unwrap();
|
||||
}
|
||||
let actual = grouped.model.state();
|
||||
let expected = reference.model.state();
|
||||
assert_eq!(actual.bodies.len(), expected.bodies.len(), "{operation}");
|
||||
for body in &actual.bodies {
|
||||
let reference = expected.body(&body.name).unwrap();
|
||||
// Fixed fine chord tolerance: cap subdivision can change the default
|
||||
// adaptive mesh's cylinder faceting without changing its analytic surface.
|
||||
let (a, b) = (body.body.tessellate(1e-5).unwrap().measure().volume, reference.body.tessellate(1e-5).unwrap().measure().volume);
|
||||
assert!((a - b).abs() < b.max(1.) * 1e-6, "{operation}: {} {a} vs {b}", body.name);
|
||||
assert!(body.body.validity().is_empty(), "{operation}: {}", body.name);
|
||||
let (a, b) = (body.mesh(), reference.mesh());
|
||||
for center in ¢ers {
|
||||
for z in [7., 11.5, 13.] {
|
||||
let point = Vec3::new(center[0], center[1], z);
|
||||
assert_eq!(a.contains(point), b.contains(point), "{operation}: {} {point:?}", body.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if operation != "intersect" {
|
||||
let unchanged = &actual.body("Other").unwrap().body;
|
||||
assert_eq!(unchanged.face_count(), other.face_count());
|
||||
assert_eq!(unchanged.color(), other.color());
|
||||
assert_eq!(unchanged.tessellate(0.01).unwrap().positions, other.tessellate(0.01).unwrap().positions, "unaffected imported body");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_target_join_keeps_nearby_bodies_and_original_absorption_order() {
|
||||
for (second_x, expected_count) in [(10.0015, 2), (9., 1)] {
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap();
|
||||
session.execute("solid.box", &json!({"corner":[second_x,8,10],"length":1,"width":1,"height":2,"body_name":"Other"})).unwrap();
|
||||
session.execute("sketch.create", &json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
|
||||
for origin in [[0., 0.], [8., 8.]] {
|
||||
session.execute("sketch.rectangle.two_point", &json!({"p0":origin,"p1":[origin[0]+2.,origin[1]+2.]})).unwrap();
|
||||
}
|
||||
session.execute("sketch.finish", &json!({})).unwrap();
|
||||
let mut reference = session.scratch();
|
||||
for index in 0..2 {
|
||||
reference.execute("solid.extrude", &json!({"sketch":"Sketch1","profiles":[index],"distance":2,"operation":"join"})).unwrap();
|
||||
}
|
||||
session.execute("solid.extrude", &json!({"distance":2,"operation":"join"})).unwrap();
|
||||
assert_eq!(session.model.state().bodies.len(), expected_count);
|
||||
assert_eq!(
|
||||
session.model.state().bodies.iter().map(|body| body.name.clone()).collect::<Vec<_>>(),
|
||||
reference.model.state().bodies.iter().map(|body| body.name.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
for body in &session.model.state().bodies {
|
||||
assert!((volume(&session, &body.name) - volume(&reference, &body.name)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two posts joined into one body while apart, trimmed by a cut that splits neither, then joined
|
||||
/// to a case they stand on. The cut split the joined posts into "Posts" and "Posts (1)" without
|
||||
/// saying so (`created_bodies` was empty), and the join then took only "Posts", leaving the other
|
||||
/// post behind as a body of its own. A cut that does cut a body in two still makes two bodies,
|
||||
/// and its reply names the new one.
|
||||
#[test]
|
||||
fn cut_keeps_posts_joined_while_apart_in_one_body() {
|
||||
let mut s = Session::default();
|
||||
let post = |name: &str, x: f64| json!({"length": 10, "width": 10, "height": 30, "center": [x, 0, 25], "operation": "new", "body_name": name});
|
||||
s.execute("solid.box", &json!({"length": 60, "width": 40, "height": 10, "center": [0, 0, 5], "operation": "new", "body_name": "Case"})).unwrap();
|
||||
s.execute("solid.box", &post("Posts", -20.0)).unwrap();
|
||||
s.execute("solid.box", &post("Post2", 20.0)).unwrap();
|
||||
s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Post2"], "operation": "join"})).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 80, "width": 20, "height": 10, "center": [0, 0, 40], "operation": "new", "body_name": "Trim"})).unwrap();
|
||||
let cut = s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Trim"], "operation": "cut"})).unwrap();
|
||||
// Each post loses its top 5 mm: 10 x 10 x 25 left of each.
|
||||
let posts = 2.0 * 10.0 * 10.0 * 25.0;
|
||||
assert_eq!(cut["bodies"], json!(["Case", "Posts"]), "{cut}");
|
||||
assert_eq!(cut["created_bodies"], json!([]), "{cut}");
|
||||
assert_eq!(s.model.state().body("Posts").map(|b| b.body.shell_count()), Some(2));
|
||||
assert!((volume(&s, "Posts") - posts).abs() < 1e-6 * posts, "{}", volume(&s, "Posts"));
|
||||
let join = s.execute("solid.combine", &json!({"target": "Case", "tools": ["Posts"], "operation": "join"})).unwrap();
|
||||
assert_eq!(join["bodies"], json!(["Case"]), "{join}");
|
||||
assert_eq!(join["removed_bodies"], json!(["Posts"]), "{join}");
|
||||
let want = 60.0 * 40.0 * 10.0 + posts;
|
||||
let got = volume(&s, "Case");
|
||||
println!("posts joined while apart, cut, joined: Case {got:.6} mm³, expected {want:.6}");
|
||||
assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
|
||||
|
||||
// A cut through the middle of the case still leaves two bodies, and says it made the second.
|
||||
s.execute("solid.box", &json!({"length": 2, "width": 80, "height": 80, "center": [0, 0, 20], "operation": "new", "body_name": "Saw"})).unwrap();
|
||||
let saw = s.execute("solid.combine", &json!({"target": "Case", "tools": ["Saw"], "operation": "cut"})).unwrap();
|
||||
assert_eq!(saw["bodies"], json!(["Case", "Case (1)"]), "{saw}");
|
||||
assert_eq!(saw["created_bodies"], json!(["Case (1)"]), "{saw}");
|
||||
let half = (60.0 - 2.0) / 2.0 * 40.0 * 10.0 + posts / 2.0;
|
||||
for name in ["Case", "Case (1)"] {
|
||||
assert!((volume(&s, name) - half).abs() < 1e-6 * half, "{name}: {} vs {half}", volume(&s, name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Designs saved before that fix combined the pieces the cut split off: tools "Posts" and
|
||||
/// "Posts (1)", or "Posts (1)" joined back into "Posts". The cut keeps them in "Posts" now, so
|
||||
/// the combine takes "Posts" and says the piece is part of it, instead of failing on
|
||||
/// "Posts (1)" and leaving both posts off the case. Any other missing body still fails.
|
||||
#[test]
|
||||
fn combine_of_a_piece_a_cut_used_to_split_off_takes_the_body_it_is_part_of() {
|
||||
let posts_cut = || {
|
||||
let mut s = Session::default();
|
||||
let post = |name: &str, x: f64| json!({"length": 10, "width": 10, "height": 30, "center": [x, 0, 25], "operation": "new", "body_name": name});
|
||||
s.execute("solid.box", &json!({"length": 60, "width": 40, "height": 10, "center": [0, 0, 5], "operation": "new", "body_name": "Case"}))
|
||||
.unwrap();
|
||||
s.execute("solid.box", &post("Posts", -20.0)).unwrap();
|
||||
s.execute("solid.box", &post("Post2", 20.0)).unwrap();
|
||||
s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Post2"], "operation": "join"})).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 80, "width": 20, "height": 10, "center": [0, 0, 40], "operation": "new", "body_name": "Trim"}))
|
||||
.unwrap();
|
||||
s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Trim"], "operation": "cut"})).unwrap();
|
||||
s
|
||||
};
|
||||
let (case, posts) = (60.0 * 40.0 * 10.0, 2.0 * 10.0 * 10.0 * 25.0);
|
||||
let close = |got: f64, want: f64| assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
|
||||
let piece = Some("`Posts (1)` is part of `Posts` now");
|
||||
|
||||
let mut s = posts_cut();
|
||||
s.execute("solid.combine", &json!({"target": "Case", "tools": ["Posts", "Posts (1)"], "operation": "join"})).unwrap();
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let join = reopened.model.results.last().unwrap();
|
||||
assert_eq!((join.error.as_deref(), join.warning.as_deref()), (None, piece));
|
||||
assert_eq!(reopened.model.state().bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Case"]);
|
||||
close(volume(&reopened, "Case"), case + posts);
|
||||
|
||||
let mut s = posts_cut();
|
||||
s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Posts (1)"], "operation": "join"})).unwrap();
|
||||
assert_eq!(s.model.results.last().unwrap().warning.as_deref(), piece);
|
||||
close(volume(&s, "Posts"), posts);
|
||||
|
||||
// A slab through both posts, 10 mm of each, cut by both pieces.
|
||||
let mut s = posts_cut();
|
||||
s.execute("solid.box", &json!({"length": 80, "width": 20, "height": 10, "center": [0, 0, 25], "operation": "new", "body_name": "Slab"})).unwrap();
|
||||
s.execute("solid.combine", &json!({"target": "Slab", "tools": ["Posts", "Posts (1)"], "operation": "cut"})).unwrap();
|
||||
assert_eq!(s.model.results.last().unwrap().warning.as_deref(), piece);
|
||||
close(volume(&s, "Slab"), 80.0 * 20.0 * 10.0 - 2.0 * 10.0 * 10.0 * 10.0);
|
||||
|
||||
for (target, tools, operation) in [
|
||||
("Case", json!(["Posts (1)"]), "join"),
|
||||
("Case", json!(["Posts", "Ghost (1)"]), "join"),
|
||||
("Case", json!(["Posts", "Posts (0)"]), "join"),
|
||||
("Posts", json!(["Posts (1)"]), "cut"),
|
||||
] {
|
||||
let r = posts_cut().execute("solid.combine", &json!({"target": target, "tools": tools, "operation": operation}));
|
||||
assert!(r.is_err(), "{target} {operation} {tools}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
|
||||
"visible: bool; items?: [sketch id or name | body name | construction name | joint name | O|X|Y|Z|XY|XZ|YZ]; \
|
||||
or joints?: [joint id or name] (their glyphs; they still constrain and move parts); \
|
||||
or folder?: bodies|sketches|construction|origin|joints (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design; not an undo step",
|
||||
or folder?: bodies|sketches|construction|origin|joints (all of it; showing also shows its browser folders and groups); \
|
||||
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, c0/g7, c0/joints, c0/canvases] (the folder or group itself: its items keep their own state); \
|
||||
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design; not an undo step",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -87,7 +89,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let component = component_of(s, p, cmd)?;
|
||||
let items = keys(p, "items", cmd)?;
|
||||
let d = s.doc_mut();
|
||||
let id = d.browser_groups.iter().map(|g| g.id).max().unwrap_or(0) + 1;
|
||||
let id = super::next_free_id(cmd, d.browser_groups.iter().map(|g| g.id))?;
|
||||
let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(n) if n.len() <= 128 => n.to_string(),
|
||||
Some(_) => return Err(bad(cmd, "`name` is too long")),
|
||||
@@ -197,9 +199,46 @@ fn shown_item(s: &Session, key: &str) -> Option<Shown> {
|
||||
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
|
||||
}
|
||||
|
||||
/// A browser folder or group key (`Visibility::hidden_folders`): `c0` or `o:<ids>`, then
|
||||
/// `/bodies`, `/sketches`, `/construction`, `/joints`, `/canvases` or `/g<group id>`.
|
||||
fn tree_folder_key(k: &str) -> bool {
|
||||
let Some((scope, last)) = k.rsplit_once('/') else { return false };
|
||||
let scope_ok =
|
||||
scope == "c0" || scope.strip_prefix("o:").is_some_and(|ids| ids.split('/').count() <= 64 && ids.split('/').all(|i| i.parse::<u64>().is_ok()));
|
||||
let last_ok =
|
||||
FOLDERS.contains(&last) || ["joints", "canvases"].contains(&last) || last.strip_prefix('g').is_some_and(|g| g.parse::<u64>().is_ok());
|
||||
k.len() <= 2048 && scope_ok && last_ok
|
||||
}
|
||||
|
||||
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.visibility";
|
||||
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
|
||||
if let Some(value) = p.get("tree_folders") {
|
||||
if ["items", "folder", "joints", "paths"].iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(bad(cmd, "give `tree_folders`, `items`, `folder`, `joints` or `paths`, not several"));
|
||||
}
|
||||
let keys = value
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= 10_000)
|
||||
.ok_or_else(|| bad(cmd, "`tree_folders` must list 1..10000 browser folder or group keys"))?;
|
||||
// Every key is checked before anything changes.
|
||||
let keys: Vec<String> = keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
k.as_str().filter(|k| tree_folder_key(k)).map(str::to_string).ok_or_else(|| {
|
||||
bad(cmd, "a browser folder or group key is `<c0 | o:ids>/<folder | g<group id>>`, such as `c0/bodies` or `o:4/g7`")
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
for k in &keys {
|
||||
s.visibility.hidden_folders.retain(|h| h != k);
|
||||
if !visible {
|
||||
s.visibility.hidden_folders.push(k.clone());
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
return Ok(json!({"visible":visible,"changed":keys.len(),"visibility":s.visibility}));
|
||||
}
|
||||
if let Some(value) = p.get("paths") {
|
||||
if p.get("items").is_some() || p.get("folder").is_some() {
|
||||
return Err(bad(cmd, "give paths, items or folder, not several"));
|
||||
@@ -291,6 +330,12 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
(None, None) => return Err(bad(cmd, "`items` or `folder` is required")),
|
||||
};
|
||||
// Showing all of a kind also shows the browser folders and groups that hold it.
|
||||
if visible && let Some(f) = str_(p, "folder") {
|
||||
let groups: Vec<String> = s.doc.browser_groups.iter().filter(|g| g.folder == f).map(|g| format!("/g{}", g.id)).collect();
|
||||
let own = format!("/{f}");
|
||||
s.visibility.hidden_folders.retain(|k| !k.ends_with(&own) && !groups.iter().any(|g| k.ends_with(g.as_str())));
|
||||
}
|
||||
let v = &mut s.visibility;
|
||||
if visible && str_(p, "folder") == Some("bodies") {
|
||||
v.hidden_occurrences.clear();
|
||||
@@ -979,6 +1024,13 @@ mod tests {
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc0) && s.undo.len() == undo0, "view state, not a design change");
|
||||
run(&mut s, "browser.visibility", json!({"folder": "sketches", "visible": true}));
|
||||
assert_eq!(lines(&s), n0);
|
||||
// The browser's Sketches folder hidden: sketches shown one by one aren't drawn either,
|
||||
// only the sketch being edited.
|
||||
assert!(s.visibility.shown_sketches.contains(&floor));
|
||||
assert!([floor, drain].iter().all(|id| !crate::view::sketch_drawn(&s, *id, false)));
|
||||
s.active_sketch = Some(floor);
|
||||
assert!(crate::view::sketch_drawn(&s, floor, false) && !crate::view::sketch_drawn(&s, drain, false));
|
||||
s.active_sketch = None;
|
||||
// Bodies (following a rename) and construction planes.
|
||||
run(&mut s, "browser.visibility", json!({"items": [body.clone(), "Floor", "xy"], "visible": false}));
|
||||
assert!(crate::view::scene(&s, &cam).meshes.is_empty());
|
||||
@@ -1004,6 +1056,31 @@ mod tests {
|
||||
assert_eq!(s.visibility, before);
|
||||
}
|
||||
|
||||
/// A design file can carry any id, the largest a `u64` holds too: a new group then has no id
|
||||
/// to take. That is an error, not an overflow (a panic where overflow is checked, a duplicate
|
||||
/// id where it is not).
|
||||
#[test]
|
||||
fn a_new_group_after_the_largest_id_is_an_error_not_an_overflow() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let a = names(&s)[0].clone();
|
||||
run(&mut s, "browser.group", json!({"folder": "bodies", "items": [a.clone()]}));
|
||||
s.doc_mut().browser_groups[0].id = u64::MAX;
|
||||
let result = s.execute("browser.group", &json!({"folder": "bodies", "items": [a]}));
|
||||
assert!(!matches!(result, Err(crate::EngineError::Internal(..))), "panicked inside the command: {result:?}");
|
||||
assert!(result.is_err(), "no id is left to give: {result:?}");
|
||||
assert_eq!(s.doc.browser_groups.len(), 1, "nothing was added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_free_id_is_one_past_the_largest_and_never_wraps() {
|
||||
use super::super::next_free_id;
|
||||
assert_eq!(next_free_id("t", std::iter::empty()).unwrap(), 1);
|
||||
assert_eq!(next_free_id("t", [3, 9, 4].into_iter()).unwrap(), 10);
|
||||
assert_eq!(next_free_id("t", [u64::MAX - 1].into_iter()).unwrap(), u64::MAX);
|
||||
assert!(next_free_id("t", [1, u64::MAX].into_iter()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_hold_items_and_follow_renames() {
|
||||
let mut s = Session::default();
|
||||
@@ -1041,4 +1118,67 @@ mod tests {
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.browser_groups.len(), 1, "ungroup undoes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_folders_and_groups_keep_their_items_state() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0]}));
|
||||
let [a, b, c] = [names(&s)[0].clone(), names(&s)[1].clone(), names(&s)[2].clone()];
|
||||
let shown = |s: &Session| [&a, &b, &c].map(|n| s.body_visible(n));
|
||||
run(&mut s, "browser.visibility", json!({"items": [a.clone()], "visible": false}));
|
||||
|
||||
// Hiding the folder hides every body; their own state is kept.
|
||||
run(&mut s, "browser.visibility", json!({"tree_folders": ["c0/bodies"], "visible": false}));
|
||||
assert_eq!(shown(&s), [false; 3]);
|
||||
assert_eq!(s.visibility.hidden_bodies, vec![a.clone()]);
|
||||
// Showing a body in a hidden folder doesn't show the folder, nor draw the body.
|
||||
run(&mut s, "browser.visibility", json!({"items": [b.clone()], "visible": true}));
|
||||
assert!(s.folder_hidden("c0/bodies") && !s.body_visible(&b));
|
||||
// Showing the folder brings back what was shown before.
|
||||
run(&mut s, "browser.visibility", json!({"tree_folders": ["c0/bodies"], "visible": true}));
|
||||
assert_eq!(shown(&s), [false, true, true]);
|
||||
// Hiding every body leaves the folder shown.
|
||||
run(&mut s, "browser.visibility", json!({"items": [a.clone(), b.clone(), c.clone()], "visible": false}));
|
||||
assert!(!s.folder_hidden("c0/bodies"), "hiding every item doesn't hide the folder");
|
||||
run(&mut s, "browser.visibility", json!({"items": [b.clone(), c.clone()], "visible": true}));
|
||||
|
||||
// A group the same way.
|
||||
let g = run(&mut s, "browser.group", json!({"folder": "bodies", "items": [a.clone(), b.clone()]}))["group"].as_u64().unwrap();
|
||||
let key = format!("c0/g{g}");
|
||||
run(&mut s, "browser.visibility", json!({"tree_folders": [key], "visible": false}));
|
||||
assert_eq!(shown(&s), [false, false, true]);
|
||||
run(&mut s, "browser.visibility", json!({"tree_folders": [key], "visible": true}));
|
||||
assert_eq!(shown(&s), [false, true, true]);
|
||||
run(&mut s, "browser.visibility", json!({"items": [a.clone(), b.clone()], "visible": false}));
|
||||
assert!(!s.folder_hidden(&key), "hiding every item doesn't hide the group");
|
||||
|
||||
// Saved with the design.
|
||||
run(&mut s, "browser.visibility", json!({"tree_folders": [key, "o:4/sketches"], "visible": false}));
|
||||
let v: solidcraft_doc::Visibility = serde_json::from_value(serde_json::to_value(&s.visibility).unwrap()).unwrap();
|
||||
assert_eq!(v.hidden_folders, vec![key.clone(), "o:4/sketches".to_string()]);
|
||||
// Showing every body also shows the folders and groups that hold bodies.
|
||||
run(&mut s, "browser.visibility", json!({"folder": "bodies", "visible": true}));
|
||||
assert_eq!(shown(&s), [true; 3]);
|
||||
assert_eq!(s.visibility.hidden_folders, vec!["o:4/sketches".to_string()]);
|
||||
|
||||
// Hostile keys are refused, and nothing changes.
|
||||
for p in [
|
||||
json!({"tree_folders": [], "visible": false}),
|
||||
json!({"tree_folders": "c0/bodies", "visible": false}),
|
||||
json!({"tree_folders": ["bodies"], "visible": false}),
|
||||
json!({"tree_folders": ["c1/bodies"], "visible": false}),
|
||||
json!({"tree_folders": ["o:/bodies"], "visible": false}),
|
||||
json!({"tree_folders": ["o:x/bodies"], "visible": false}),
|
||||
json!({"tree_folders": ["c0/gx"], "visible": false}),
|
||||
json!({"tree_folders": ["c0/nope"], "visible": false}),
|
||||
json!({"tree_folders": [7], "visible": false}),
|
||||
json!({"tree_folders": ["c0/bodies"], "items": [c.clone()], "visible": false}),
|
||||
json!({"tree_folders": [format!("o:{}1/bodies", "1/".repeat(70))], "visible": false}),
|
||||
] {
|
||||
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.visibility.hidden_folders, vec!["o:4/sketches".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use solidcraft_doc::Document;
|
||||
use solidcraft_doc::joints::cam::CamProfile;
|
||||
use solidcraft_doc::joints::{Assembly, Coupling, JointOrigin, Size, Snap};
|
||||
use solidcraft_doc::joints::{Assembly, Coupling, JointOrigin, Size};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
@@ -26,12 +26,7 @@ fn add_references(count: &mut usize, more: usize) -> Result<()> {
|
||||
}
|
||||
|
||||
fn finite_origin(origin: &JointOrigin) -> Result<()> {
|
||||
let finite = match &origin.snap {
|
||||
Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()),
|
||||
Snap::FaceCenter { pick, .. } | Snap::CircleCenter { pick, .. } => pick.is_finite(),
|
||||
Snap::Point { point, z } => point.is_finite() && z.is_none_or(|z| z.is_finite()),
|
||||
};
|
||||
if !finite {
|
||||
if !origin.snap.is_finite() {
|
||||
return Err(invalid("joint origin contains non-finite geometry"));
|
||||
}
|
||||
Ok(())
|
||||
@@ -61,7 +56,7 @@ fn budget(assembly: &Assembly) -> Result<()> {
|
||||
for origin in &assembly.origins {
|
||||
finite_origin(&origin.origin)?;
|
||||
text(&origin.name)?;
|
||||
if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.origin.snap {
|
||||
for face in origin.origin.snap.face_names() {
|
||||
text(face)?;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +72,7 @@ fn budget(assembly: &Assembly) -> Result<()> {
|
||||
}
|
||||
for origin in [&joint.a, &joint.b] {
|
||||
finite_origin(origin)?;
|
||||
if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.snap {
|
||||
for face in origin.snap.face_names() {
|
||||
text(face)?;
|
||||
}
|
||||
}
|
||||
@@ -234,9 +229,8 @@ fn origin(
|
||||
) -> Option<JointOrigin> {
|
||||
let mut out = origin.clone();
|
||||
out.occurrence = *occurrences.get(&origin.occurrence)?;
|
||||
match &mut out.snap {
|
||||
Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } => *face = face_name(face, features, bodies)?,
|
||||
_ => {}
|
||||
for face in out.snap.face_names_mut() {
|
||||
*face = face_name(face, features, bodies)?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use super::*;
|
||||
use solidcraft_doc::joints::{Coupling, Joint, JointKind, MotionLink, NamedOrigin, Size};
|
||||
use solidcraft_doc::joints::{Coupling, Joint, JointKind, MotionLink, NamedOrigin, Size, Snap, XRef};
|
||||
use solidcraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey};
|
||||
use solidcraft_doc::{IDENTITY, Occurrence};
|
||||
use solidcraft_geom::Vec3;
|
||||
@@ -35,11 +35,18 @@ fn documents() -> (Document, Document) {
|
||||
source.assembly.origins = vec![
|
||||
NamedOrigin {
|
||||
name: "Typed origin".into(),
|
||||
origin: JointOrigin { occurrence: 1, snap: Snap::FaceCenter { pick: Vec3::new(1.0, 2.0, 3.0), face: Some("F1:start@F10.2".into()) } },
|
||||
origin: JointOrigin {
|
||||
occurrence: 1,
|
||||
snap: Snap::FaceCenter {
|
||||
pick: Vec3::new(1.0, 2.0, 3.0),
|
||||
face: Some("F1:start@F10.2".into()),
|
||||
x: Some(XRef::Face { face: "F10:side".into(), pick: None }),
|
||||
},
|
||||
},
|
||||
},
|
||||
NamedOrigin {
|
||||
name: "Foreign geometry".into(),
|
||||
origin: JointOrigin { occurrence: 1, snap: Snap::CircleCenter { pick: Vec3::ZERO, face: Some("F99:end".into()) } },
|
||||
origin: JointOrigin { occurrence: 1, snap: Snap::CircleCenter { pick: Vec3::ZERO, face: Some("F99:end".into()), end: None, x: None } },
|
||||
},
|
||||
NamedOrigin { name: "Outside".into(), origin: at(9) },
|
||||
];
|
||||
@@ -79,7 +86,9 @@ fn internal_relationships_receive_independent_ids_and_remapped_typed_references(
|
||||
assert_eq!(a.joints[4].limits, source.assembly.joints[0].limits);
|
||||
assert_eq!(a.joints[4].name, "Joint1 (copy)");
|
||||
assert_eq!(a.origins.len(), 4);
|
||||
assert!(matches!(&a.origins[3].origin.snap, Snap::FaceCenter { face: Some(face), .. } if face == "F11:start@F110.2"));
|
||||
// Its x reference's face is the copy's too.
|
||||
assert!(matches!(&a.origins[3].origin.snap, Snap::FaceCenter { face: Some(face), x: Some(XRef::Face { face: x, .. }), .. }
|
||||
if face == "F11:start@F110.2" && x == "F110:side"));
|
||||
assert_eq!((a.links[2].a, a.links[2].b, a.links[2].ratio), (5, 6, -2.0));
|
||||
assert_eq!(a.contact_sets[2].id, 7);
|
||||
assert_eq!(a.contact_sets[2].occurrences, vec![101, 102]);
|
||||
@@ -103,7 +112,12 @@ fn internal_relationships_receive_independent_ids_and_remapped_typed_references(
|
||||
#[test]
|
||||
fn copied_gears_take_their_picked_faces_along() {
|
||||
let (mut source, _) = documents();
|
||||
let circle = |face: &str| Size::Circle(JointOrigin { occurrence: 1, snap: Snap::CircleCenter { pick: Vec3::X, face: Some(face.into()) } });
|
||||
let circle = |face: &str| {
|
||||
Size::Circle(JointOrigin {
|
||||
occurrence: 1,
|
||||
snap: Snap::CircleCenter { pick: Vec3::X, face: Some(face.into()), end: Some("F1:top".into()), x: None },
|
||||
})
|
||||
};
|
||||
let gear = |ra| Coupling::Gear { ra, rb: Size::Length(5.0), internal: false, reverse: false };
|
||||
source.assembly.links = vec![
|
||||
MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.0, coupling: Some(gear(circle("F1:side"))), lift: Vec::new() },
|
||||
@@ -116,7 +130,10 @@ fn copied_gears_take_their_picked_faces_along() {
|
||||
.unwrap();
|
||||
let copied = destination.assembly.links.get(2).and_then(|l| l.coupling.clone());
|
||||
let Some(Coupling::Gear { ra: Size::Circle(o), .. }) = copied else { panic!("{:?}", destination.assembly.links) };
|
||||
assert!(o.occurrence == 101 && matches!(&o.snap, Snap::CircleCenter { face: Some(f), .. } if f == "F11:side"), "{o:?}");
|
||||
assert!(
|
||||
o.occurrence == 101 && matches!(&o.snap, Snap::CircleCenter { face: Some(f), end: Some(e), .. } if f == "F11:side" && e == "F11:top"),
|
||||
"{o:?}"
|
||||
);
|
||||
assert_eq!(destination.assembly.links.len(), 3, "the gear on foreign geometry stays behind");
|
||||
assert!(report.iter().any(|m| m.contains("motion link")), "{report:?}");
|
||||
}
|
||||
@@ -216,3 +233,27 @@ fn paste_new_internal_motion_study_drives_only_the_copied_assembly_and_reopens()
|
||||
assert_eq!(reopened.assembly, s.doc.assembly);
|
||||
assert!(reopened.assembly.studies.iter().any(|m| m.name == "Travel (copy)" && m.keys[0].joint == copied_joint));
|
||||
}
|
||||
|
||||
/// An edge origin's edge, face and corner edge are the copy's too.
|
||||
#[test]
|
||||
fn an_edge_origin_is_copied_onto_the_copys_edges() {
|
||||
let (source, mut destination) = documents();
|
||||
let edge = |e: &str| Some(e.to_string());
|
||||
let mut source = source;
|
||||
source.assembly.origins = vec![NamedOrigin {
|
||||
name: "Corner".into(),
|
||||
origin: JointOrigin {
|
||||
occurrence: 1,
|
||||
snap: Snap::Edge { pick: Vec3::new(1.0, 2.0, 3.0), edge: edge("F1:top|F1:side"), face: edge("F1:top"), corner: edge("F1:top|F10:end") },
|
||||
},
|
||||
}];
|
||||
destination.assembly.origins.clear();
|
||||
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new())
|
||||
.unwrap();
|
||||
let copied = destination.assembly.origins.last().map(|o| &o.origin.snap);
|
||||
assert!(
|
||||
matches!(copied, Some(Snap::Edge { edge: Some(e), face: Some(f), corner: Some(c), .. })
|
||||
if e == "F11:top|F11:side" && f == "F11:top" && c == "F11:top|F110:end"),
|
||||
"{copied:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -978,6 +978,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.chain(super::features_more::COMMANDS)
|
||||
.chain(super::construct::COMMANDS)
|
||||
.chain(super::surface::COMMANDS)
|
||||
.chain(super::surface_boundary::COMMANDS)
|
||||
.any(|candidate| candidate.id == spec.id)
|
||||
|| matches!(
|
||||
spec.id,
|
||||
@@ -1049,7 +1050,10 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
*after = before.clone();
|
||||
}
|
||||
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
|
||||
// A boundary surface's loop of sketch curves and its curve network are one command's two
|
||||
// forms: an edit may turn one into the other.
|
||||
let boundary = |k: &FeatureKind| matches!(k, FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. });
|
||||
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) && !(boundary(&new.kind) && boundary(&old.kind)) {
|
||||
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
|
||||
}
|
||||
// Optional inputs can move other inputs' positions (e.g. Distance 2 goes before Taper).
|
||||
|
||||
@@ -84,6 +84,10 @@ type FacePick = (std::sync::Arc<Mesh>, u32, f64, String);
|
||||
/// equally near (bodies touching flush), the pick is ambiguous: `Err` names those bodies.
|
||||
fn face_at_in(s: &Session, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
|
||||
let st = s.model.state();
|
||||
face_at_state(&st, p, only)
|
||||
}
|
||||
|
||||
pub(super) fn face_at_state(st: &solidcraft_doc::ModelState, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
|
||||
let mut best: Option<FacePick> = None;
|
||||
let mut tied: Vec<String> = Vec::new();
|
||||
for b in st.bodies.iter().filter(|b| only.is_none_or(|n| b.name == n)) {
|
||||
@@ -127,6 +131,80 @@ fn xy(v: Vec2) -> Value {
|
||||
json!([v.x, v.y])
|
||||
}
|
||||
|
||||
/// Coplanar faces share one linked profile sketch and one Extrude feature. Resolve every
|
||||
/// pick before editing the document, so later picks never search an already modified body.
|
||||
pub(super) fn sketch_of_faces(s: &mut Session, points: &[Vec3], cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec<Vec2>, String)> {
|
||||
let first = points.first().copied().ok_or_else(|| bad(cmd, "faces must not be empty"))?;
|
||||
if points.len() > 1024 {
|
||||
return Err(bad(cmd, "faces exceed the 1024-face limit"));
|
||||
}
|
||||
let state = s.model.state();
|
||||
let mut picked = Vec::new();
|
||||
let mut common: Option<(String, solidcraft_geom::Plane)> = None;
|
||||
for &point in points {
|
||||
if !point.is_finite() {
|
||||
return Err(bad(cmd, "faces must be finite points"));
|
||||
}
|
||||
let (mesh, index, distance, name) = face_at_in(s, point, face_body)
|
||||
.map_err(|_| bad(cmd, "ambiguous face; specify face_body"))?
|
||||
.ok_or_else(|| bad(cmd, "no body face at a selected point"))?;
|
||||
let body = state.body(&name).ok_or_else(|| bad(cmd, "selected body disappeared"))?;
|
||||
let plane = body.body.planar_face(index as usize).ok_or_else(|| bad(cmd, "faces must be planar"))?;
|
||||
let tolerance = (body.body.size() * 1e-9).max(1e-8);
|
||||
if distance > (mesh.bounds().diagonal() * 1e-3).max(1e-3) * 10.0 {
|
||||
return Err(bad(cmd, "no body face at a selected point"));
|
||||
}
|
||||
if let Some((previous, base)) = &common {
|
||||
if *previous != name || base.normal().dot(plane.normal()) < 1.0 - 1e-9 || base.height(plane.origin).abs() > tolerance {
|
||||
return Err(bad(cmd, "faces must be coplanar, equally oriented faces of one body"));
|
||||
}
|
||||
} else {
|
||||
common = Some((name.clone(), plane));
|
||||
}
|
||||
if picked.iter().any(|(face, _)| *face == index as usize) {
|
||||
continue;
|
||||
}
|
||||
let inside = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, face)| **face == index)
|
||||
.filter_map(|(triangle, _)| mesh.tri(triangle))
|
||||
.max_by(|[a, b, c], [x, y, z]| ((*b - *a).cross(*c - *a).len()).total_cmp(&((*y - *x).cross(*z - *x).len())))
|
||||
.map(|[a, b, c]| (a + b + c) / 3.0)
|
||||
.ok_or_else(|| bad(cmd, "degenerate face"))?;
|
||||
picked.push((index as usize, inside));
|
||||
}
|
||||
let (id, first_inside, _) = sketch_of_face(s, first, cmd, face_body)?;
|
||||
let (body, _) = common.ok_or_else(|| bad(cmd, "no faces selected"))?;
|
||||
let doc = s.placed_document();
|
||||
let source = state.body(&body).ok_or_else(|| bad(cmd, "source body disappeared"))?;
|
||||
let component = doc.model_body_component(source);
|
||||
let path = if component == s.active_component {
|
||||
s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(component))
|
||||
} else {
|
||||
let placements = super::component::properties::placements(&doc, cmd)?;
|
||||
if placements.iter().filter(|(owner, _, _)| *owner == component).count() != 1 {
|
||||
return Err(bad(cmd, "activate the selected occurrence before extruding its faces"));
|
||||
}
|
||||
doc.first_path(component)
|
||||
};
|
||||
let placement = EmbossFace::component_frame(&doc, component, &path)?;
|
||||
let placement = doc.model_body_offset(source).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement);
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component));
|
||||
let author = EmbossFace::component_frame(&doc, s.active_component, &author_path)?;
|
||||
let inverse = mat_inverse(&author).ok_or_else(|| bad(cmd, "invalid face profile author frame"))?;
|
||||
let into_author = mat_mul(&inverse, &placement);
|
||||
drop(doc);
|
||||
let indices: Vec<_> = picked.iter().skip(1).map(|(index, _)| *index).collect();
|
||||
super::sketch_project::project_internal_faces(s, id, &body, &indices)?;
|
||||
s.refresh();
|
||||
let plane = s.model.state().sketch(id).map(|sketch| sketch.plane).ok_or_else(|| bad(cmd, "face profile sketch disappeared"))?;
|
||||
let mut selected = vec![first_inside];
|
||||
selected.extend(picked.iter().skip(1).map(|(_, point)| plane.to_local(apply_point(&into_author, *point))));
|
||||
Ok((id, selected, body))
|
||||
}
|
||||
|
||||
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
|
||||
/// the sketch id, a point inside the face (sketch coordinates), and the picked body's name.
|
||||
/// The sketch is left active.
|
||||
|
||||
@@ -18,7 +18,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
@@ -56,7 +56,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("hole")
|
||||
.key("H")
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
|
||||
CommandSpec::new("solid.pipe", "Pipe", pipe)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pipe")
|
||||
@@ -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)
|
||||
@@ -527,10 +547,11 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
}
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
// The bodies this feature made, apart from the ones it changed or that were there (#374).
|
||||
let created: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
// Bodies that were there before and now have other geometry, and bodies that are gone.
|
||||
let old: std::collections::HashMap<&str, &solidcraft_doc::kernel::Body> = before.bodies.iter().map(|b| (b.name.as_str(), &b.body)).collect();
|
||||
// The bodies this feature made, apart from the ones it changed or that were there (#374),
|
||||
// and the pieces a cut left of a body made earlier, which keep that body's feature.
|
||||
let created: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id || !old.contains_key(b.name.as_str())).map(|b| b.name.as_str()).collect();
|
||||
let now: std::collections::HashSet<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
let changed: Vec<&str> =
|
||||
st.bodies.iter().filter(|b| b.feature != id && old.get(b.name.as_str()).is_some_and(|o| !o.same(&b.body))).map(|b| b.name.as_str()).collect();
|
||||
@@ -545,13 +566,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";
|
||||
// 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 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) = 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 +604,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");
|
||||
@@ -1646,7 +1685,13 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let first =
|
||||
ids.first().and_then(|id| ss.sketch.points.iter().find(|q| &q.id == id)).map(|q| ss.plane.to_world(q.pos)).unwrap_or_default();
|
||||
let flip = p.get("flip").and_then(Value::as_bool).unwrap_or(false);
|
||||
Some((solidcraft_doc::SketchPoints { sketch: SketchId(sketch), ids, flip }, first, -n))
|
||||
// A Single hole at one sketch point (#2110): it follows the point, drilled into the
|
||||
// face there rather than across the sketch.
|
||||
let single = p.get("single").and_then(Value::as_bool).unwrap_or(false);
|
||||
if single && (ids.len() != 1 || flip) {
|
||||
return Err(bad(cmd, "a `single` hole takes one sketch point and no `flip`"));
|
||||
}
|
||||
Some((solidcraft_doc::SketchPoints { sketch: SketchId(sketch), ids, flip, single }, first, -n))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
@@ -1688,7 +1733,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, None) => return Err(bad(cmd, "`position` must be [x, y, z], numbers or length expressions (or give `sketch` and `points`)")),
|
||||
};
|
||||
let direction = match (p.get("direction").and_then(vec3), &points) {
|
||||
(None, Some((_, _, d))) => *d,
|
||||
(None, Some((sp, _, d))) if !sp.single => *d,
|
||||
(d, _) => match d {
|
||||
Some(d) => d.normalized().ok_or_else(|| bad(cmd, "`direction` must be non-zero"))?,
|
||||
None => {
|
||||
@@ -2119,6 +2164,10 @@ mod prism_local_tests;
|
||||
#[path = "revolve_convention_tests.rs"]
|
||||
mod revolve_convention_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "single_hole_tests.rs"]
|
||||
mod single_hole_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sweep_options_tests.rs"]
|
||||
mod sweep_options_tests;
|
||||
|
||||
@@ -25,11 +25,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.rib", "Rib", rib)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("rib")
|
||||
.params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane); depth? (default: to the next face); flip?: bool (fill to the other side)"),
|
||||
.params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane, or across the curves when normal); depth? (default: to the next face); flip?: bool (fill to the other side); direction?: in_plane|normal (grow across the sketch plane, the default, or along its normal)"),
|
||||
CommandSpec::new("solid.web", "Web", web)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("web")
|
||||
.params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool"),
|
||||
.params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool; direction?: in_plane|normal"),
|
||||
CommandSpec::new("solid.emboss", "Emboss", emboss)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("emboss")
|
||||
@@ -133,7 +133,12 @@ fn rib_like(s: &mut Session, p: &Value, cmd: &str, web: bool) -> Result<Value> {
|
||||
check_expr(s, d, Kind::Length, cmd, "depth")?;
|
||||
}
|
||||
let flip = bool_(p, "flip").unwrap_or(false);
|
||||
add_feature(s, p, FeatureKind::Rib { sketch: SketchId(sketch), curves, thickness, depth, flip, web })
|
||||
let normal = match str_(p, "direction").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("in_plane" | "plane") => false,
|
||||
Some("normal") => true,
|
||||
Some(o) => return Err(bad(cmd, format!("unknown direction `{o}` (in_plane or normal)"))),
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Rib { sketch: SketchId(sketch), curves, thickness, depth, flip, web, normal })
|
||||
}
|
||||
|
||||
fn rib(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -2,6 +2,63 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn grouped_face_auto_operation_keeps_implicit_and_explicit_participants() {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"Part"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
for offset in [0., 30.] {
|
||||
if offset != 0. {
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":part["occurrence"],"translate":[offset,0,0]}));
|
||||
}
|
||||
// To Object follows the target's side, even when the distance and
|
||||
// direction fields still describe the opposite extrusion.
|
||||
for (height, expected) in [(8., "cut"), (12., "join")] {
|
||||
let params = json!({"faces":[[offset+2.,2,10],[offset+8.,8,10]],"face_body":"Stock",
|
||||
"to":[offset+5.,5,height],"direction":"negative"});
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some(expected));
|
||||
}
|
||||
for distance in [-2., 2.] {
|
||||
let mut params = json!({"faces":[[offset+2.,2,10],[offset+8.,8,10]],"distance":distance});
|
||||
let expected = if distance < 0. { "cut" } else { "join" };
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some(expected));
|
||||
params["face_body"] = json!("Stock");
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some(expected));
|
||||
let mut actual = s.scratch();
|
||||
if offset != 0. {
|
||||
run(&mut actual, "component.activate", json!({"component":part["component"]}));
|
||||
}
|
||||
params["operation"] = json!("auto");
|
||||
let made = run(&mut actual, "solid.extrude", params);
|
||||
let body = actual.model.state().body("Stock").unwrap().body.clone();
|
||||
let volume = solidcraft_kernel::measure(&body).unwrap().volume;
|
||||
assert!(
|
||||
(volume - (1000. + 100. * distance)).abs() < 1e-6,
|
||||
"offset={offset}, distance={distance}, volume={volume}, feature={:?}",
|
||||
actual.doc.feature(made["feature"].as_u64().unwrap())
|
||||
);
|
||||
}
|
||||
if offset != 0. {
|
||||
// Outside the edited component, grouping keeps the original explicit
|
||||
// face command's coordinate and participant behavior.
|
||||
let mut grouped = s.scratch();
|
||||
let mut single = s.scratch();
|
||||
run(&mut grouped, "solid.extrude", json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2,"operation":"cut"}));
|
||||
run(&mut single, "solid.extrude", json!({"face":[32,2,10],"face_body":"Stock","distance":-2,"operation":"cut"}));
|
||||
let volume = |session: &Session| solidcraft_kernel::measure(&session.model.state().body("Stock").unwrap().body).unwrap().volume;
|
||||
assert!((volume(&grouped) - volume(&single)).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
run(&mut s, "solid.box", json!({"corner":[100,0,0],"length":10,"width":10,"height":10,"body_name":"Other"}));
|
||||
let mut params = json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2});
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some("join"), "an existing active-component body keeps its original participant scope");
|
||||
params["participants"] = json!(["Stock"]);
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some("cut"));
|
||||
params["participants"] = json!(["Other"]);
|
||||
assert_eq!(crate::auto_operation(&s, ¶ms), Some("join"));
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
@@ -28,6 +85,43 @@ fn volume(s: &mut Session) -> f64 {
|
||||
measure(s)["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separated_profile_cut_preserves_participants_and_removes_only_consumed_bodies() {
|
||||
for explicit in [false, true] {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 10), ("C", 30)] {
|
||||
run(&mut s, "solid.box", json!({"length":5,"width":5,"height":10,"corner":[x,0,0],"body_name":name}));
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Cutters"}));
|
||||
for (a, b) in [([-1, -1], [6, 6]), ([12, 2], [14, 4]), ([32, 2], [34, 4])] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":a,"p1":b}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = s.doc.to_json();
|
||||
let mut params = json!({"sketch":"Cutters","profiles":"all","distance":10,"operation":"cut"});
|
||||
if explicit {
|
||||
params["participants"] = json!(["A", "B"]);
|
||||
}
|
||||
run(&mut s, "solid.extrude", params);
|
||||
let check = |session: &Session| {
|
||||
let state = session.model.state();
|
||||
assert_eq!(state.bodies.len(), 2);
|
||||
assert!(state.body("A").is_none());
|
||||
for (name, expected) in [("B", 210.), ("C", if explicit { 250. } else { 210. })] {
|
||||
let body = &state.body(name).unwrap().body;
|
||||
assert!((solidcraft_kernel::measure(body).unwrap().volume - expected).abs() < 1e-6);
|
||||
assert!(body.validity().is_empty());
|
||||
}
|
||||
};
|
||||
check(&s);
|
||||
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
@@ -77,6 +171,28 @@ fn rib_with_a_depth_and_web() {
|
||||
assert!(e.is_err_and(|e| e.to_string().contains("do not face a body")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rib_along_the_sketch_normal_reaches_the_body() {
|
||||
// A 20 x 20 x 10 box; a line along X on a sketch 10 in front of it (y = -10), whose plane
|
||||
// the box does not cross, so only a rib along the sketch normal can reach it.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, -10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Front"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[2, 5], [18, 5]], "ids": ["l1"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let e = s.execute("solid.rib", &json!({"sketch": "Front", "curves": ["l1"], "thickness": 2}));
|
||||
assert!(e.is_err_and(|e| e.to_string().contains("do not face a body")));
|
||||
run(&mut s, "solid.rib", json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "normal"}));
|
||||
// 16 long, 2 thick, 10 from the sketch to the box.
|
||||
assert!(rel(volume(&mut s), 4000.0 + 320.0) < 1e-6, "{}", volume(&mut s));
|
||||
assert_eq!(measure(&mut s)["body_count"], 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "solid.rib", json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "normal", "depth": 4}));
|
||||
assert!(rel(volume(&mut s), 4000.0 + 128.0) < 1e-6, "{}", volume(&mut s));
|
||||
let e = s.execute("solid.rib", &json!({"sketch": "Front", "curves": ["l1"], "thickness": 2, "direction": "sideways"}));
|
||||
assert!(e.is_err_and(|e| e.to_string().contains("unknown direction")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emboss_and_deboss_on_a_face() {
|
||||
let mut s = Session::default();
|
||||
@@ -836,6 +952,165 @@ fn a_tapered_foot_under_a_part_joins_as_drawn() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A drafted extrude that starts above its sketch plane. The start offset moves the profile
|
||||
/// along the sketch normal and the walls are drafted from there, so the section at the start
|
||||
/// plane is the profile itself and the solid is the frustum over it, moved along the normal by
|
||||
/// the offset. Controls that build today: the same extrude with the taper and no offset, and
|
||||
/// with the offset and no taper. Two-sided and symmetric tapers are still refused, by name.
|
||||
#[test]
|
||||
fn a_drafted_extrude_starts_at_an_offset() {
|
||||
use solidcraft_kernel::{FaceSurface, face_surface, measure as measure_body};
|
||||
// A 30 x 20 profile, 12 mm long, drafted 6 deg, started 4 mm along the normal.
|
||||
let (w, d, h, off) = (30.0, 20.0, 12.0, 4.0);
|
||||
let part = |p: Value| -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[w,d]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", p);
|
||||
s
|
||||
};
|
||||
// The straight prism, and the frustum over the profile: every edge moves out by z*tan a
|
||||
// over the start, so A(z) = (w + 2 z t)(d + 2 z t) and
|
||||
// V = integral of A over 0..h = w*d*h + (w + d)*t*h^2 + 4/3*t^2*h^3 (exact: A is quadratic).
|
||||
let prism = w * d * h;
|
||||
let frustum = |t: f64| w * d * h + (w + d) * t * h * h + 4.0 / 3.0 * t * t * h * h * h;
|
||||
// The two caps plus four trapezoid walls, each (base + top)/2 by the slant height h/cos a.
|
||||
let area_of = |a: f64| {
|
||||
let t = a.tan();
|
||||
w * d + (w + 2.0 * h * t) * (d + 2.0 * h * t) + 2.0 * h / a.cos() * ((w + h * t) + (d + h * t))
|
||||
};
|
||||
|
||||
// Control: the taper alone, from the sketch plane.
|
||||
let want = frustum(6f64.to_radians().tan());
|
||||
let mut s = part(json!({"distance":h,"taper":"6 deg","body_name":"B"}));
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, want) < 1e-9, "taper alone: {v} mm3 for {want}");
|
||||
|
||||
// Control: the offset alone, no taper: the prism, moved along the normal.
|
||||
let mut s = part(json!({"distance":h,"start_offset":off,"body_name":"B"}));
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, prism) < 1e-9, "offset alone: {v} mm3 for {prism}");
|
||||
let st = s.model.state();
|
||||
let m = measure_body(&st.body("B").unwrap().body).unwrap();
|
||||
assert!((m.bbox.min.z - off).abs() < 1e-9 && (m.bbox.max.z - (off + h)).abs() < 1e-9, "offset alone: {:?}", m.bbox);
|
||||
|
||||
// Both: the frustum over the profile, standing on the offset plane, either way along the
|
||||
// normal and drafted either outward or inward.
|
||||
for deg in [6.0f64, -6.0] {
|
||||
for (dir, sign) in [("positive", 1.0f64), ("negative", -1.0)] {
|
||||
let (ang, t) = (deg.to_radians(), deg.to_radians().tan());
|
||||
let (v_want, a_want) = (frustum(t), area_of(ang));
|
||||
let at = format!("{deg} deg {dir}");
|
||||
let mut s = part(json!({"distance":h,"direction":dir,"start_offset":off,"taper":format!("{deg} deg"),"body_name":"B"}));
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, v_want) < 1e-9, "{at}: {v} mm3 for {v_want}");
|
||||
let st = s.model.state();
|
||||
let b = &st.body("B").unwrap().body;
|
||||
let m = measure_body(b).unwrap();
|
||||
assert_eq!(st.bodies.len(), 1, "{at}: one body");
|
||||
assert_eq!(m.shells, 1, "{at}: one lump");
|
||||
assert_eq!(m.merged.faces, 6, "{at}: four walls and two caps");
|
||||
// The area pins both caps: the profile at the offset plane, the drafted section at
|
||||
// the far end. (The volume alone cannot: it is measured on a tessellation.)
|
||||
assert!(rel(m.area, a_want) < 1e-9, "{at}: {} mm2 for {a_want}", m.area);
|
||||
// The solid lies between the offset plane and the far end, and the widening (or
|
||||
// narrowing) is all on the far side of the profile.
|
||||
let (lo, far) = if sign > 0.0 { (off, off + h) } else { (off - h, off) };
|
||||
assert!((m.bbox.min.z - lo).abs() < 1e-9 && (m.bbox.max.z - (lo + h)).abs() < 1e-9, "{at}: {:?}", m.bbox);
|
||||
let (x0, x1) = (0f64.min(-h * t), w.max(w + h * t));
|
||||
let (y0, y1) = (0f64.min(-h * t), d.max(d + h * t));
|
||||
assert!((m.bbox.min.x - x0).abs() < 1e-9 && (m.bbox.max.x - x1).abs() < 1e-9, "{at}: {:?} for x {x0}..{x1}", m.bbox);
|
||||
assert!((m.bbox.min.y - y0).abs() < 1e-9 && (m.bbox.max.y - y1).abs() < 1e-9, "{at}: {:?} for y {y0}..{y1}", m.bbox);
|
||||
// Every face names an exact surface: four planes leaning by the taper and two flat
|
||||
// caps, one of them the profile's plane at the offset. A tessellated stand-in
|
||||
// measures nearly the same volume and names no surface at all.
|
||||
let (mut walls, mut caps) = (0, 0);
|
||||
for i in 0..b.face_count() {
|
||||
match face_surface(b, i) {
|
||||
Some(FaceSurface::Plane { normal, point }) if normal.z.abs() > 1.0 - 1e-9 => {
|
||||
caps += 1;
|
||||
assert!((point.z - lo).abs() < 1e-9 || (point.z - far).abs() < 1e-9, "{at}: a cap at z = {}", point.z);
|
||||
}
|
||||
Some(FaceSurface::Plane { normal, .. }) => {
|
||||
walls += 1;
|
||||
assert!((normal.z + sign * ang.sin()).abs() < 1e-9, "{at}: a wall leaning {}, not {}", normal.z, -sign * ang.sin());
|
||||
}
|
||||
other => panic!("{at}: face {i} is {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!((walls, caps), (4, 2), "{at}");
|
||||
}
|
||||
}
|
||||
|
||||
// The extents that are still to come are refused, and say which they are.
|
||||
for p in [json!({"distance":h,"distance2":5,"taper":"6 deg"}), json!({"distance":h,"direction":"symmetric","taper":"6 deg"})] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[w,d]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let e = s.execute("solid.extrude", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet: taper with two-sided or symmetric extents"), "{p}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A drafted pocket that starts above its sketch plane, cut into a body three booleans deep:
|
||||
/// its rim lies on a face bounded by an intersection curve, not by straight lines. The tool
|
||||
/// lies wholly in material, so the cut removes exactly the frustum over its profile.
|
||||
#[test]
|
||||
fn a_drafted_offset_cut_into_a_body_with_history() {
|
||||
use solidcraft_kernel::{FaceSurface, face_surface, measure as measure_body};
|
||||
let (w, d, h, off) = (30.0, 20.0, 12.0, 18.0);
|
||||
let (ang, t) = (6f64.to_radians(), 6f64.to_radians().tan());
|
||||
let frustum = w * d * h + (w + d) * t * h * h + 4.0 / 3.0 * t * t * h * h * h;
|
||||
let unnamed = |b: &solidcraft_kernel::Body| (0..b.face_count()).filter(|i| face_surface(b, *i).is_none()).count();
|
||||
|
||||
let mut s = Session::default();
|
||||
// A 60 x 40 x 30 block, a round boss joined through its far side and out of its top, and a
|
||||
// round pocket cut in the top: the top face is bounded by the boss's intersection curve.
|
||||
run(&mut s, "solid.box", json!({"length":60,"width":40,"height":30,"corner":[0,0,0],"body_name":"P"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius":8,"height":46,"base":[58,20,-8],"operation":"join"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius":4,"height":12,"base":[10,34,20],"operation":"cut"}));
|
||||
let before = volume(&mut s);
|
||||
{
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.len(), 1, "the block, the boss and the pocket are one body");
|
||||
assert_eq!(unnamed(&st.body("P").unwrap().body), 0, "faces without a surface before the cut");
|
||||
}
|
||||
|
||||
// The pocket: drafted 6 deg outward, starting 18 mm up the normal and out through the top.
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[35,25]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":h,"start_offset":off,"taper":"6 deg","operation":"cut"}));
|
||||
let after = volume(&mut s);
|
||||
let removed = before - after;
|
||||
assert!((removed - frustum).abs() < 1e-6 * frustum, "{removed} mm3 removed for the frustum's {frustum}");
|
||||
|
||||
let st = s.model.state();
|
||||
let b = &st.body("P").unwrap().body;
|
||||
let m = measure_body(b).unwrap();
|
||||
assert_eq!(st.bodies.len(), 1, "one body");
|
||||
assert_eq!(m.shells, 1, "one lump: the pocket is open at the top");
|
||||
assert_eq!(unnamed(b), 0, "faces without a surface after the cut");
|
||||
// Four drafted walls, and the pocket's floor at the offset: the only leaning planes of the
|
||||
// body (the block's own faces are level or upright), and the only face in the plane the cut
|
||||
// starts on. A cut keeps the tool's sides facing into the pocket, so a wall drafted outward
|
||||
// by a leans by +sin a, the other way round from the same extrude as a new body.
|
||||
let (mut walls, mut floor) = (0, 0);
|
||||
for i in 0..b.face_count() {
|
||||
let Some(FaceSurface::Plane { normal, point }) = face_surface(b, i) else { continue };
|
||||
if (normal.z - ang.sin()).abs() < 1e-9 {
|
||||
walls += 1;
|
||||
} else if normal.z > 1.0 - 1e-9 && (point.z - off).abs() < 1e-9 {
|
||||
floor += 1;
|
||||
} else {
|
||||
assert!(normal.z.abs() < 1e-9 || normal.z.abs() > 1.0 - 1e-9, "a plane leaning {}, neither the block's nor the pocket's", normal.z);
|
||||
}
|
||||
}
|
||||
assert_eq!((walls, floor), (4, 1), "four drafted walls, and the pocket's floor at z = {off}");
|
||||
}
|
||||
|
||||
/// A rounded part as drawn in the app: a rectangle and a quarter circle tangent to its top
|
||||
/// line, extruded together. The arc (about (62.17, -21), radius 21) ends square at the front
|
||||
/// face and in a knife-edge tip where it runs into the back face.
|
||||
|
||||
@@ -294,12 +294,23 @@ fn saved_name(name: &str, old_path: Option<&str>, path: &str) -> Option<String>
|
||||
/// Rename the design, and the undo and redo states that still carry the old name, so undo
|
||||
/// does not bring the default name back and leave the design looking changed.
|
||||
fn rename_design(s: &mut Session, from: &str, to: &str) {
|
||||
// A prepared world is keyed by the document's Arc. Changing only its name
|
||||
// must not make an opted-in save lose warm geometry and display caches.
|
||||
// Retag only a cache that already matches: cold or stale placements stay so.
|
||||
let model = s.model.state();
|
||||
let keep_world = s.world_cache.lock().ok().is_some_and(|cache| cache.as_ref().is_some_and(|cache| cache.matches(s, &model)));
|
||||
s.doc_mut().name = to.to_string();
|
||||
for snap in s.undo.iter_mut().chain(s.redo.iter_mut()) {
|
||||
if snap.doc.name == from {
|
||||
std::sync::Arc::make_mut(&mut snap.doc).name = to.to_string();
|
||||
}
|
||||
}
|
||||
if keep_world
|
||||
&& let Ok(mut cache) = s.world_cache.lock()
|
||||
&& let Some(cache) = cache.as_mut()
|
||||
{
|
||||
cache.doc = std::sync::Arc::clone(&s.doc);
|
||||
}
|
||||
}
|
||||
|
||||
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result<Value> {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Joint origins on an edge of a face (its middle, or a corner) follow the face as it changes size.
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::tests::{place, run};
|
||||
use crate::Session;
|
||||
|
||||
/// A 2 mm panel `W` x 300 (W = 400) in component Panel, and a 20 x 10 x 6 bracket in component
|
||||
/// Bracket, off to the side.
|
||||
fn panel() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "W", "expression": "400 mm"}));
|
||||
run(&mut s, "component.create", json!({"name": "Panel"}));
|
||||
run(&mut s, "solid.box", json!({"length": "W", "width": 300, "height": 2, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Bracket"}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 6, "corner": [0, 500, 0]}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The middle of the bracket's bottom face, where it is now.
|
||||
fn bracket(s: &Session) -> Vec3 {
|
||||
place(s, "Bracket", Vec3::new(10.0, 505.0, 0.0))
|
||||
}
|
||||
|
||||
fn near(a: Vec3, b: Vec3) -> bool {
|
||||
a.dist(b) < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_set_in_from_a_corner_stays_there_as_the_panel_grows() {
|
||||
let mut s = panel();
|
||||
// The front right corner of the top face; the bracket 40 mm in along both edges.
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "planar", "a": {"occurrence": "Panel", "edge": [200, 0, 2], "corner": [400, 0, 2]},
|
||||
"b": {"occurrence": "Bracket", "face": [10, 505, 0]}, "values": [40, 40, 0], "name": "Corner"}),
|
||||
);
|
||||
for w in [400.0, 600.0, 250.0, 1000.0] {
|
||||
run(&mut s, "parameters.change", json!({"name": "W", "expression": format!("{w} mm")}));
|
||||
assert!(near(bracket(&s), Vec3::new(w - 40.0, 40.0, 2.0)), "W {w}: {:?}", bracket(&s));
|
||||
assert_eq!(run(&mut s, "joint.solve", json!({}))["errors"], json!([]));
|
||||
}
|
||||
let snap = s.doc.assembly.joints.first().map(|j| j.a.snap.clone());
|
||||
assert!(
|
||||
matches!(&snap, Some(solidcraft_doc::joints::Snap::Edge { edge: Some(_), face: Some(_), corner: Some(_), .. })),
|
||||
"the edge, the face and the corner's other edge are kept by name: {snap:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_at_an_edges_middle_follows_it_and_the_face_can_be_chosen() {
|
||||
let mut s = panel();
|
||||
// The right edge of the top face: its middle is (W, 150, 2), y points into the face (−X).
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "planar", "a": {"occurrence": "Panel", "edge": [400, 100, 2]},
|
||||
"b": {"occurrence": "Bracket", "face": [10, 505, 0]}, "values": [0, 40, 0], "name": "Mid"}),
|
||||
);
|
||||
for w in [400.0, 700.0, 320.0] {
|
||||
run(&mut s, "parameters.change", json!({"name": "W", "expression": format!("{w} mm")}));
|
||||
assert!(near(bracket(&s), Vec3::new(w - 40.0, 150.0, 2.0)), "W {w}: {:?}", bracket(&s));
|
||||
}
|
||||
// The same edge on the end face: z points out of the end (+X), y into it (down).
|
||||
run(&mut s, "joint.delete", json!({"joint": "Mid"}));
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "edge": [320, 100, 2], "on": [320, 150, 1]},
|
||||
"b": {"occurrence": "Bracket", "face": [10, 505, 0]}, "name": "End"}),
|
||||
);
|
||||
assert!(near(bracket(&s), Vec3::new(320.0, 150.0, 2.0)), "{:?}", bracket(&s));
|
||||
// The bracket's bottom faces the end face: its top (6 mm up its own z) is out along +X.
|
||||
assert!(
|
||||
near(place(&s, "Bracket", Vec3::new(10.0, 505.0, 6.0)), Vec3::new(326.0, 150.0, 2.0)),
|
||||
"{:?}",
|
||||
place(&s, "Bracket", Vec3::new(10.0, 505.0, 6.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_origins_refuse_bad_picks_and_report_a_lost_edge() {
|
||||
let mut s = panel();
|
||||
let bad = |s: &mut Session, a: serde_json::Value| {
|
||||
s.execute("joint.create", &json!({"type": "rigid", "a": a, "b": {"occurrence": "Bracket", "face": [10, 505, 0]}})).unwrap_err().to_string()
|
||||
};
|
||||
// Not on an edge; a face the edge doesn't bound.
|
||||
assert!(bad(&mut s, json!({"occurrence": "Panel", "edge": [200, 150, 2]})).contains("no edge there"));
|
||||
assert!(bad(&mut s, json!({"occurrence": "Panel", "edge": [200, 0, 2], "on": [200, 150, 0]})).contains("doesn't bound"));
|
||||
// A design whose edge name no longer finds anything: the joint is left out with the reason.
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "edge": [200, 0, 2]}, "b": {"occurrence": "Bracket", "face": [10, 505, 0]}, "name": "J"}),
|
||||
);
|
||||
let mut doc = (*s.doc).clone();
|
||||
if let Some(j) = doc.assembly.joints.first_mut()
|
||||
&& let solidcraft_doc::joints::Snap::Edge { edge, .. } = &mut j.a.snap
|
||||
{
|
||||
*edge = Some("F999:nothing|F998:nowhere".into());
|
||||
}
|
||||
*s.doc_mut() = doc;
|
||||
let r = run(&mut s, "joint.solve", json!({}));
|
||||
assert!(r["errors"][0]["why"].as_str().is_some_and(|w| w.contains("no longer exists")), "{r}");
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! A joint origin on a face or a hole keeps its turn about z as the geometry tilts.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::tests::{occ, run};
|
||||
use crate::Session;
|
||||
|
||||
/// A 40 x 40 x 2 plate in component Panel, turned about Y by the `tilt` parameter (a Move feature),
|
||||
/// and a 10 x 4 x 4 bar in component Bar, long along its X.
|
||||
fn tilted(tilt: f64) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "tilt", "expression": format!("{tilt} deg")}));
|
||||
run(&mut s, "component.create", json!({"name": "Panel"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 2, "corner": [-20, -20, 0]}));
|
||||
let plate = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap_or_default();
|
||||
run(&mut s, "solid.move", json!({"bodies": [plate], "axis": [0, 1, 0], "angle": "tilt"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Bar"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 4, "height": 4, "corner": [100, 0, 0]}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A point on the plate's top face (the largest face facing up).
|
||||
fn top(s: &mut Session) -> Value {
|
||||
let body = s.model.state().bodies.first().map(|b| b.name.clone()).unwrap_or_default();
|
||||
let faces = run(s, "model.faces", json!({"body": body}));
|
||||
let best = faces["faces"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|f| f["plane_normal"][2].as_f64().is_some_and(|z| z > 0.3))
|
||||
.max_by(|a, b| a["area"].as_f64().unwrap_or(0.0).total_cmp(&b["area"].as_f64().unwrap_or(0.0)))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
best["point"].clone()
|
||||
}
|
||||
|
||||
/// How far the bar's long axis is turned from Y, the plate's axis of tilt (degrees, 0…90).
|
||||
fn bar_from_tilt_axis(s: &Session) -> f64 {
|
||||
let m = solidcraft_doc::joints::occurrence_world(&s.doc, occ(s, "Bar"));
|
||||
let x = solidcraft_doc::apply_vector(&m, Vec3::X);
|
||||
x.dot(Vec3::Y).abs().min(1.0).acos().to_degrees()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_on_a_tilting_face_keeps_its_turn_where_the_face_normal_crosses_0_9_along_x() {
|
||||
// The plate starts at 60°: its normal is 0.87 along X. Past 64.2° it is over 0.9, where a
|
||||
// frame worked out from the normal alone picks its x another way and the bar turned a
|
||||
// right angle on the face.
|
||||
let mut s = tilted(60.0);
|
||||
let a = top(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "face": a}, "b": {"occurrence": "Bar", "face": [105, 2, 0]}, "name": "Seat"}),
|
||||
);
|
||||
let start = bar_from_tilt_axis(&s);
|
||||
for tilt in [62.0, 64.0, 66.0, 70.0, 80.0, 50.0, 30.0] {
|
||||
run(&mut s, "parameters.change", json!({"name": "tilt", "expression": format!("{tilt} deg")}));
|
||||
let now = bar_from_tilt_axis(&s);
|
||||
assert!((now - start).abs() < 1e-6, "at {tilt}°: the bar is {now:.3}° from the tilt axis, {start:.3}° at first");
|
||||
let r = run(&mut s, "joint.solve", json!({}));
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
}
|
||||
// The origin keeps that x in the design.
|
||||
let snap = s.doc.assembly.joints.first().map(|j| j.a.snap.clone());
|
||||
assert!(snap.as_ref().and_then(solidcraft_doc::joints::Snap::frame_x).is_some(), "{snap:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_origin_saved_without_a_kept_x_is_placed_as_before() {
|
||||
let mut s = tilted(30.0);
|
||||
let a = top(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": "Panel", "face": a}, "b": {"occurrence": "Bar", "face": [105, 2, 0]}, "name": "Seat"}),
|
||||
);
|
||||
let kept = bar_from_tilt_axis(&s);
|
||||
// The same joint as an older design has it: no x kept on either side. It is placed as
|
||||
// a frame from the normal alone places it, which here is where the kept x placed it too.
|
||||
let mut doc = (*s.doc).clone();
|
||||
for j in &mut doc.assembly.joints {
|
||||
for o in [&mut j.a, &mut j.b] {
|
||||
if let solidcraft_doc::joints::Snap::FaceCenter { x, .. } | solidcraft_doc::joints::Snap::CircleCenter { x, .. } = &mut o.snap {
|
||||
*x = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = serde_json::to_string(&doc.assembly).unwrap_or_default();
|
||||
assert!(!text.contains("\"x\""), "{text}");
|
||||
*s.doc_mut() = doc;
|
||||
run(&mut s, "joint.solve", json!({}));
|
||||
assert!((bar_from_tilt_axis(&s) - kept).abs() < 1e-6);
|
||||
}
|
||||
+125
-32
@@ -8,7 +8,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::joints::cam::{self, Cam, CamProfile, Follower};
|
||||
use solidcraft_doc::joints::{self, Coupling, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap};
|
||||
use solidcraft_doc::joints::{self, Coupling, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap, XRef};
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
@@ -17,10 +17,10 @@ use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
|
||||
"name?, occurrence?: id|name (default: from the pick), face: [x,y,z] (centre of a planar face) | circle: [x,y,z] (centre of a round end) | point: [x,y,z], z?: [x,y,z]",
|
||||
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | edge: [x,y,z] (a point on an edge of a planar face: the edge's middle, z out of the face, y into it; on?: [x,y,z] a point on the face (default: the larger planar face beside the edge); corner?: [x,y,z] near one end: the corner there, x and y along its two edges into the face; found again by name, so it follows the face as it changes size) | point: [x,y,z], z?: [x,y,z]; body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
|
||||
),
|
||||
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
|
||||
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball; a, b: joint origins ({occurrence, face|circle|point: [x,y,z] in world} | origin name); \
|
||||
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball; a, b: joint origins ({occurrence, face|circle|edge|point: [x,y,z] in world, or face|circle: \"<face name>\"; end?, x? as joint.origin} | origin name); \
|
||||
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool (default false: the origins are mated, \
|
||||
b's z against a's as two faces meet; true: b's z along a's, so two origins given at one point with the same z stay as they are); \
|
||||
offset?; angle?; name?; quiet?: bool (leave out dof and fixed)",
|
||||
@@ -275,11 +275,15 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<JointOrigin
|
||||
.map(|o| o.origin.clone())
|
||||
.ok_or_else(|| bad(cmd, format!("no joint origin `{name}`")));
|
||||
}
|
||||
let pick = ["face", "circle", "point"].iter().find_map(|k| v.get(*k).and_then(vec3).map(|p| (*k, p)));
|
||||
// A face given by its name, as `model.faces` lists it.
|
||||
if let Some((what, name)) = ["face", "circle"].iter().find_map(|k| v.get(*k).and_then(Value::as_str).map(|n| (*k, n))) {
|
||||
return origin_by_name(s, v, what, name, cmd);
|
||||
}
|
||||
let pick = ["face", "circle", "edge", "point"].iter().find_map(|k| v.get(*k).and_then(vec3).map(|p| (*k, p)));
|
||||
let (what, world) = match (pick, v.get("frame")) {
|
||||
(Some(x), _) => x,
|
||||
(None, Some(f)) => ("frame", f.get("origin").and_then(vec3).ok_or_else(|| bad(cmd, "`frame` needs origin and z"))?),
|
||||
_ => return Err(bad(cmd, "a joint origin needs face, circle, point or frame [x, y, z]")),
|
||||
_ => return Err(bad(cmd, "a joint origin needs face, circle, edge, point or frame [x, y, z], or a face's name")),
|
||||
};
|
||||
let occurrence = match v.get("occurrence") {
|
||||
Some(o) => occurrence_id(s, Some(o), cmd)?,
|
||||
@@ -290,17 +294,19 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<JointOrigin
|
||||
let inv = solidcraft_doc::mat_inverse(&t).ok_or_else(|| bad(cmd, "occurrence transform"))?;
|
||||
let local = |p: Vec3| solidcraft_doc::apply_point(&inv, p);
|
||||
let local_dir = |d: Vec3| solidcraft_doc::apply_vector(&inv, d);
|
||||
let component = component_of(s, occurrence);
|
||||
let st = s.model.state();
|
||||
let snap = match what {
|
||||
"face" | "circle" => {
|
||||
let pick = local(world);
|
||||
// A circle's name is its cylindrical wall's (a rim pick also touches the end face).
|
||||
let named_at = if what == "circle" {
|
||||
s.model.state().bodies.iter().filter_map(|b| cylinder_wall_point(&b.body, pick)).next().unwrap_or(pick)
|
||||
} else {
|
||||
pick
|
||||
};
|
||||
let face = solidcraft_doc::naming::face_names_at(&s.model.state(), &[named_at]).into_iter().next().filter(|n| !n.is_empty());
|
||||
if what == "face" { Snap::FaceCenter { pick, face } } else { Snap::CircleCenter { pick, face } }
|
||||
// The face's name, which it is found by from then on (a circle's is its wall's).
|
||||
let face = joints::face_name_at(&s.doc, &st, component, pick, what == "circle");
|
||||
let x = x_param(s, v, component, &inv, cmd)?;
|
||||
if what == "face" { Snap::FaceCenter { pick, face, x } } else { Snap::CircleCenter { pick, face, end: None, x } }
|
||||
}
|
||||
"edge" => {
|
||||
let (on, corner) = (v.get("on").and_then(vec3).map(local), v.get("corner").and_then(vec3).map(local));
|
||||
joints::edge_snap(&s.doc, &st, component, local(world), on, corner).map_err(|e| bad(cmd, e.to_string()))?
|
||||
}
|
||||
"point" => Snap::Point { point: local(world), z: v.get("z").and_then(vec3).map(local_dir) },
|
||||
_ => {
|
||||
@@ -310,13 +316,100 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<JointOrigin
|
||||
}
|
||||
};
|
||||
let o = JointOrigin { occurrence, snap };
|
||||
match joints::resolve_origin(&s.doc, &s.model.state(), &o) {
|
||||
Ok(_) => Ok(o),
|
||||
// A circle given by its centre, where no face lies (#393).
|
||||
Err(e) => match (what == "circle").then(|| circle_centre(s, occurrence, local(world))).flatten() {
|
||||
Some(snap) => Ok(JointOrigin { occurrence, snap }),
|
||||
None => Err(bad(cmd, e.to_string())),
|
||||
match settle(s, o, v, cmd) {
|
||||
Ok(o) => Ok(o),
|
||||
// A circle given by its centre, where no face lies (#393): that end of the cylinder, by
|
||||
// its face's name and its end's (a face without a name: the point and axis there).
|
||||
Err(e) if what == "circle" => {
|
||||
let snap = match joints::round_end_at(&s.doc, &st, component, local(world)) {
|
||||
Some(Snap::CircleCenter { pick, face, end, .. }) => Snap::CircleCenter { pick, face, end, x: x_param(s, v, component, &inv, cmd)? },
|
||||
_ => circle_centre(s, occurrence, local(world)).ok_or(e)?,
|
||||
};
|
||||
settle(s, JointOrigin { occurrence, snap }, v, cmd)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The component an occurrence places (0 for the root, or an unknown occurrence).
|
||||
fn component_of(s: &Session, occurrence: u64) -> u64 {
|
||||
if occurrence == 0 { 0 } else { s.doc.occurrences.iter().find(|o| o.id == occurrence).map_or(0, |o| o.component) }
|
||||
}
|
||||
|
||||
/// A joint origin on a face given by its name (`face: "<name>"`: its centre; `circle: "<name>"`:
|
||||
/// a round end of it), on `body?`, of `occurrence?` (default: the first occurrence of the
|
||||
/// face's component).
|
||||
fn origin_by_name(s: &Session, v: &Value, what: &str, name: &str, cmd: &str) -> Result<JointOrigin> {
|
||||
if name.is_empty() || name.len() > 512 {
|
||||
return Err(bad(cmd, "a face name is 1…512 characters"));
|
||||
}
|
||||
let st = s.model.state();
|
||||
let (component, pick) = joints::named_face_spot(&s.doc, &st, str_(v, "body"), name).map_err(|why| bad(cmd, why))?;
|
||||
let occurrence = match v.get("occurrence") {
|
||||
Some(o) => occurrence_id(s, Some(o), cmd)?,
|
||||
None if component == 0 => 0,
|
||||
None => {
|
||||
s.doc.occurrence_of(component).map(|o| o.id).ok_or_else(|| bad(cmd, format!("face `{name}` is on a component with no occurrence")))?
|
||||
}
|
||||
};
|
||||
if component_of(s, occurrence) != component {
|
||||
return Err(bad(cmd, format!("face `{name}` is not on that occurrence's component")));
|
||||
}
|
||||
let t = if occurrence == 0 { solidcraft_doc::IDENTITY } else { joints::occurrence_world(&s.doc, occurrence) };
|
||||
let inv = solidcraft_doc::mat_inverse(&t).ok_or_else(|| bad(cmd, "occurrence transform"))?;
|
||||
let x = x_param(s, v, component, &inv, cmd)?;
|
||||
let face = Some(name.to_string());
|
||||
let snap = if what == "face" { Snap::FaceCenter { pick, face, x } } else { Snap::CircleCenter { pick, face, end: None, x } };
|
||||
settle(s, JointOrigin { occurrence, snap }, v, cmd)
|
||||
}
|
||||
|
||||
/// A new joint origin checked: `end?` (a circle's end, by the name of the face across its rim
|
||||
/// there) set, and the end it takes named so it keeps it; refused when it doesn't resolve.
|
||||
fn settle(s: &Session, mut o: JointOrigin, v: &Value, cmd: &str) -> Result<JointOrigin> {
|
||||
let st = s.model.state();
|
||||
if let Some(want) = v.get("end") {
|
||||
let want =
|
||||
want.as_str().filter(|n| !n.is_empty() && n.len() <= 512).ok_or_else(|| bad(cmd, "`end` is the name of a face a round end lies on"))?;
|
||||
let Snap::CircleCenter { end, .. } = &mut o.snap else { return Err(bad(cmd, "`end` is for a round origin (`circle`)")) };
|
||||
*end = Some(want.to_string());
|
||||
let names = joints::end_faces(&s.doc, &st, &o.snap);
|
||||
if !names.iter().any(|n| n == want) {
|
||||
return Err(bad(cmd, format!("the round face doesn't end on face `{want}` (its ends lie on: {})", names.join(", "))));
|
||||
}
|
||||
}
|
||||
let m = joints::resolve_origin(&s.doc, &st, &o).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
joints::pin_end(&s.doc, &st, &mut o.snap);
|
||||
// A face or circle given no `x` keeps the x it has now, so its frame doesn't turn as the face
|
||||
// tilts.
|
||||
o.snap = o.snap.with_frame_x(Vec3::new(m[0][0], m[0][1], m[0][2]));
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// A joint origin's x reference (its turn about z): `x: [x, y, z]` (a direction, world), `x:
|
||||
/// "<face name>"`, or `x: {face: [x, y, z]}` (the face of the origin's component there, world).
|
||||
fn x_param(s: &Session, v: &Value, component: u64, inv: &solidcraft_doc::Mat, cmd: &str) -> Result<Option<XRef>> {
|
||||
let st = s.model.state();
|
||||
let named = |name: &str| -> Result<Option<XRef>> {
|
||||
joints::named_face_spot(&s.doc, &st, None, name).map_err(|why| bad(cmd, format!("`x`: {why}")))?;
|
||||
Ok(Some(XRef::Face { face: name.to_string(), pick: None }))
|
||||
};
|
||||
let wrong = || bad(cmd, "`x` is a direction [x, y, z], a face's name, or {face: [x, y, z]}");
|
||||
match v.get("x") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(name)) if !name.is_empty() && name.len() <= 512 => named(name),
|
||||
Some(o @ Value::Object(_)) => match o.get("face") {
|
||||
Some(Value::String(name)) if !name.is_empty() && name.len() <= 512 => named(name),
|
||||
Some(p) => {
|
||||
let pick = vec3(p).map(|p| solidcraft_doc::apply_point(inv, p)).ok_or_else(wrong)?;
|
||||
let face = joints::face_name_at(&s.doc, &st, component, pick, false).ok_or_else(|| bad(cmd, "`x`: no named face there"))?;
|
||||
Ok(Some(XRef::Face { face, pick: Some(pick) }))
|
||||
}
|
||||
None => Err(wrong()),
|
||||
},
|
||||
Some(d) => {
|
||||
let d = vec3(d).filter(|d| d.is_finite() && d.len() > 1e-9).ok_or_else(wrong)?;
|
||||
Ok(Some(XRef::Dir(solidcraft_doc::apply_vector(inv, d))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,8 +486,8 @@ fn limits_param(s: &Session, p: &Value, kind: JointKind, cmd: &str) -> Result<Ve
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn next_joint_id(s: &Session) -> u64 {
|
||||
s.doc.assembly.joints.iter().map(|j| j.id).max().unwrap_or(0) + 1
|
||||
fn next_joint_id(s: &Session, cmd: &str) -> Result<u64> {
|
||||
super::next_free_id(cmd, s.doc.assembly.joints.iter().map(|j| j.id))
|
||||
}
|
||||
|
||||
fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
|
||||
@@ -474,7 +567,7 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = kind_param(p, cmd)?;
|
||||
let a = origin_param(s, p.get("a"), cmd)?;
|
||||
let b = origin_param(s, p.get("b"), cmd)?;
|
||||
let id = next_joint_id(s);
|
||||
let id = next_joint_id(s, cmd)?;
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Joint{id}"));
|
||||
let offset = match p.get("offset") {
|
||||
Some(v) => value(s, v, false, cmd)?,
|
||||
@@ -514,7 +607,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let fa = s.doc.frame_in_occurrence(oa, &w).ok_or_else(|| bad(cmd, "occurrence transform"))?;
|
||||
let fb = s.doc.frame_in_occurrence(ob, &w).ok_or_else(|| bad(cmd, "occurrence transform"))?;
|
||||
let id = next_joint_id(s);
|
||||
let id = next_joint_id(s, cmd)?;
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Joint{id}"));
|
||||
let n = kind.dofs().len();
|
||||
let j = Joint {
|
||||
@@ -1230,18 +1323,18 @@ pub(crate) fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, u
|
||||
(pairs, skipped)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "joint_edge_tests.rs"]
|
||||
mod edge_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_face_tests.rs"]
|
||||
mod face_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joint_frames_tests.rs"]
|
||||
mod frames_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_reach_tests.rs"]
|
||||
mod reach_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// A point in the middle of the cylindrical face at `pick` (on its wall, halfway along).
|
||||
fn cylinder_wall_point(b: &solidcraft_doc::kernel::Body, pick: Vec3) -> Option<Vec3> {
|
||||
let c = solidcraft_doc::kernel::cylinder_face_at(b, pick)?;
|
||||
let v = pick - c.axis_point;
|
||||
let along = v.dot(c.axis);
|
||||
let radial = (v - c.axis * along).normalized()?;
|
||||
Some(c.axis_point + c.axis * ((c.start + c.end) / 2.0) + radial * c.radius)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Joint origins found by their face's name: a part jointed to a hole stays on it, on the same
|
||||
//! side and turned the same way, as the design's parameters change.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::joints::{Snap, XRef};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::tests::{occ, place, run};
|
||||
use crate::Session;
|
||||
|
||||
fn near(a: Vec3, b: Vec3) -> bool {
|
||||
a.dist(b) < 1e-6
|
||||
}
|
||||
|
||||
/// Widths swept: small steps (a hole's wall in a 1 mm plate is a few long facets, and a pick
|
||||
/// stored at the old width lies beside the moved hole) and large ones.
|
||||
const WIDTHS: [f64; 9] = [302.0, 304.0, 310.0, 330.0, 400.0, 450.0, 510.0, 301.0, 300.0];
|
||||
|
||||
/// A tray: a 1 mm plate `W` wide (a parameter, 300 mm to start) and 100 deep, with a 6 mm hole
|
||||
/// 20 mm in from its right edge (an expression); grounded. A bracket: a 20 x 20 x 2 foot with a
|
||||
/// 6 mm hole in its middle and a 15 mm upright along its +X edge, so a turn shows.
|
||||
fn tray() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "W", "expression": "300 mm"}));
|
||||
run(&mut s, "component.create", json!({"name": "Tray"}));
|
||||
run(&mut s, "solid.box", json!({"length": "W", "width": 100, "height": 1, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "solid.hole", json!({"position": ["W - 20 mm", 50, 1], "diameter": 6}));
|
||||
let tray = occ(&s, "Tray");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": tray, "grounded": true}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Bracket"}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 2, "corner": [100, 200, 0]}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 20, "height": 15, "corner": [118, 200, 0], "operation": "join"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [110, 210, 2], "diameter": 6}));
|
||||
s
|
||||
}
|
||||
|
||||
fn width(s: &mut Session, w: f64) {
|
||||
run(s, "parameters.change", json!({"name": "W", "expression": format!("{w} mm")}));
|
||||
let r = run(s, "joint.solve", json!({}));
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "W = {w}: {r}");
|
||||
}
|
||||
|
||||
/// The bracket sits on the tray's top, its hole on the tray's hole and its upright toward +X.
|
||||
fn on_the_hole(s: &Session, w: f64) {
|
||||
let hole = place(s, "Bracket", Vec3::new(110.0, 210.0, 0.0));
|
||||
assert!(near(hole, Vec3::new(w - 20.0, 50.0, 1.0)), "W = {w}: the bracket's hole is at {hole:?}");
|
||||
let upright = place(s, "Bracket", Vec3::new(119.0, 210.0, 15.0));
|
||||
assert!(near(upright, Vec3::new(w - 11.0, 50.0, 16.0)), "W = {w}: the upright's top is at {upright:?}");
|
||||
}
|
||||
|
||||
fn origin(s: &Session, joint: &str, a: bool) -> Snap {
|
||||
let j = s.doc.assembly.joints.iter().find(|j| j.name == joint).map(|j| if a { &j.a } else { &j.b });
|
||||
j.map(|o| o.snap.clone()).unwrap_or(Snap::Point { point: Vec3::ZERO, z: None })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bracket_on_a_hole_in_thin_plate_stays_on_it_as_the_plate_widens() {
|
||||
let mut s = tray();
|
||||
// Picks inside the 1 mm wall of the tray's hole (nearer its top) and the 2 mm wall of the
|
||||
// bracket's (nearer its bottom); both x along +X.
|
||||
let r = run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "name": "Mount",
|
||||
"a": {"occurrence": "Tray", "circle": [283, 50, 0.8], "x": [1, 0, 0]},
|
||||
"b": {"occurrence": "Bracket", "circle": [113, 210, 0.4], "x": [1, 0, 0]}}),
|
||||
);
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
on_the_hole(&s, 300.0);
|
||||
// Both origins are kept by their wall's name and their end's (the tray's top face, the
|
||||
// bracket's bottom one).
|
||||
for a in [true, false] {
|
||||
let snap = origin(&s, "Mount", a);
|
||||
assert!(matches!(&snap, Snap::CircleCenter { face: Some(_), end: Some(_), x: Some(XRef::Dir(_)), .. }), "{snap:?}");
|
||||
}
|
||||
for w in WIDTHS {
|
||||
width(&mut s, w);
|
||||
on_the_hole(&s, w);
|
||||
}
|
||||
// Saved and opened again, it still follows.
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-joint-faces-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("tray.json").to_string_lossy().to_string();
|
||||
run(&mut s, "file.save", json!({"path": path}));
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
for w in [330.0, 510.0] {
|
||||
width(&mut s, w);
|
||||
on_the_hole(&s, w);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circle_given_by_its_centre_follows_its_hole() {
|
||||
let mut s = tray();
|
||||
// The centres of the tray hole's top rim and the bracket hole's bottom rim.
|
||||
let r = run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "name": "Mount",
|
||||
"a": {"occurrence": "Tray", "circle": [280, 50, 1], "x": [1, 0, 0]},
|
||||
"b": {"occurrence": "Bracket", "circle": [110, 210, 0], "x": [1, 0, 0]}}),
|
||||
);
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let snap = origin(&s, "Mount", true);
|
||||
assert!(matches!(&snap, Snap::CircleCenter { face: Some(_), end: Some(_), .. }), "a centre names its face and end: {snap:?}");
|
||||
on_the_hole(&s, 300.0);
|
||||
for w in WIDTHS {
|
||||
width(&mut s, w);
|
||||
on_the_hole(&s, w);
|
||||
}
|
||||
}
|
||||
|
||||
/// The name `model.faces` gives the face of `component`'s body holding `p` (component coordinates).
|
||||
fn face_name(s: &mut Session, component: &str, p: Vec3) -> String {
|
||||
let st = s.model.state();
|
||||
let c = s.doc.find_component(component).unwrap_or(0);
|
||||
solidcraft_doc::joints::face_name_at(&s.doc, &st, c, p, false).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origins_take_faces_by_name_and_an_end_and_turn_by_name() {
|
||||
let mut s = tray();
|
||||
let wall = face_name(&mut s, "Tray", Vec3::new(283.0, 50.0, 0.5));
|
||||
let top = face_name(&mut s, "Tray", Vec3::new(150.0, 50.0, 1.0));
|
||||
let bottom = face_name(&mut s, "Tray", Vec3::new(150.0, 50.0, 0.0));
|
||||
let right = face_name(&mut s, "Tray", Vec3::new(300.0, 50.0, 0.5));
|
||||
let foot = face_name(&mut s, "Bracket", Vec3::new(113.0, 210.0, 1.0));
|
||||
let under = face_name(&mut s, "Bracket", Vec3::new(105.0, 205.0, 0.0));
|
||||
assert!(![&wall, &top, &bottom, &right, &foot, &under].iter().any(|n| n.is_empty()));
|
||||
// A round end by the wall's name and the face it opens onto; x along the tray's right end
|
||||
// face's normal (+X).
|
||||
run(&mut s, "joint.origin", json!({"name": "Hole", "circle": wall, "end": top, "x": right}));
|
||||
let r = run(&mut s, "joint.create", json!({"type": "rigid", "name": "Mount", "a": "Hole", "b": {"circle": foot, "end": under, "x": [1, 0, 0]}}));
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
on_the_hole(&s, 300.0);
|
||||
for w in [304.0, 400.0, 510.0] {
|
||||
width(&mut s, w);
|
||||
on_the_hole(&s, w);
|
||||
}
|
||||
// The other end: the bracket hangs under the plate (its foot's underside on the plate's).
|
||||
run(&mut s, "joint.origin", json!({"name": "Under", "circle": wall, "end": bottom, "x": right}));
|
||||
run(&mut s, "joint.delete", json!({"joint": "Mount"}));
|
||||
run(&mut s, "joint.create", json!({"type": "rigid", "name": "Hung", "a": "Under", "b": {"circle": foot, "end": under, "x": [1, 0, 0]}}));
|
||||
let hole = place(&s, "Bracket", Vec3::new(110.0, 210.0, 0.0));
|
||||
assert!(near(hole, Vec3::new(490.0, 50.0, 0.0)), "{hole:?}");
|
||||
assert!(place(&s, "Bracket", Vec3::new(119.0, 210.0, 15.0)).z < -10.0);
|
||||
// An end the hole doesn't have, a missing face and a bad x are refused.
|
||||
for bad in [
|
||||
json!({"circle": wall, "end": right}),
|
||||
json!({"circle": "F999:nothing"}),
|
||||
json!({"circle": wall, "x": "F999:nothing"}),
|
||||
json!({"circle": wall, "x": [0, 0, 0]}),
|
||||
json!({"face": [10, 10, 1], "end": top}),
|
||||
] {
|
||||
assert!(s.execute("joint.origin", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_face_centre_by_name_follows_its_face() {
|
||||
let mut s = tray();
|
||||
let right = face_name(&mut s, "Tray", Vec3::new(300.0, 50.0, 0.5));
|
||||
run(&mut s, "joint.origin", json!({"name": "End", "face": right, "x": [0, 1, 0]}));
|
||||
let frame = |s: &Session| {
|
||||
let o = s.doc.assembly.origins.iter().find(|o| o.name == "End").map(|o| o.origin.clone());
|
||||
o.and_then(|o| solidcraft_doc::joints::resolve_origin(&s.doc, &s.model.state(), &o).ok())
|
||||
};
|
||||
for w in [300.0, 330.0, 510.0] {
|
||||
width(&mut s, w);
|
||||
let m = frame(&s).unwrap_or(solidcraft_doc::IDENTITY);
|
||||
let (o, x, z) = (Vec3::new(m[3][0], m[3][1], m[3][2]), Vec3::new(m[0][0], m[0][1], m[0][2]), Vec3::new(m[2][0], m[2][1], m[2][2]));
|
||||
assert!(near(o, Vec3::new(w, 50.0, 0.5)) && near(z, Vec3::X) && near(x, Vec3::Y), "W = {w}: {o:?} {x:?} {z:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_origin_without_an_end_or_turn_opens_as_before() {
|
||||
// A design saved before origins named their end and turn: the snap reads without them.
|
||||
let snap: Snap = serde_json::from_value(json!({"type": "circle_center", "pick": [1, 2, 3], "face": "F1:side"}))
|
||||
.unwrap_or(Snap::Point { point: Vec3::ZERO, z: None });
|
||||
assert_eq!(snap, Snap::CircleCenter { pick: Vec3::new(1.0, 2.0, 3.0), face: Some("F1:side".into()), end: None, x: None });
|
||||
let back: Value = serde_json::to_value(&snap).unwrap_or_default();
|
||||
assert_eq!(back, json!({"type": "circle_center", "pick": [1.0, 2.0, 3.0], "face": "F1:side"}));
|
||||
let turned = Snap::FaceCenter { pick: Vec3::ZERO, face: None, x: Some(XRef::Face { face: "F2:+x".into(), pick: None }) };
|
||||
let v = serde_json::to_value(&turned).unwrap_or_default();
|
||||
assert_eq!(v["x"], json!({"face": "F2:+x"}));
|
||||
assert_eq!(serde_json::from_value::<Snap>(v).ok(), Some(turned));
|
||||
}
|
||||
|
||||
/// The frame a named joint origin resolves to: its origin and z.
|
||||
fn origin_frame(s: &Session, name: &str) -> Option<(Vec3, Vec3)> {
|
||||
let o = s.doc.assembly.origins.iter().find(|o| o.name == name)?;
|
||||
let m = solidcraft_doc::joints::resolve_origin(&s.doc, &s.model.state(), &o.origin).ok()?;
|
||||
Some((Vec3::new(m[3][0], m[3][1], m[3][2]), Vec3::new(m[2][0], m[2][1], m[2][2])))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_origin_saved_without_an_end_takes_the_end_it_took_before() {
|
||||
// A plate `T` thick (5 mm) with a 6 mm hole through it, and two round origins as builds
|
||||
// before `end` saved them (no `end`, no `x`): picks on the hole's wall 4 mm and 1 mm up.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "T", "expression": "5 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": "T", "corner": [0, 0, 0]}));
|
||||
run(&mut s, "solid.hole", json!({"position": [20, 20, "T"], "diameter": 6}));
|
||||
let wall = solidcraft_doc::joints::face_name_at(&s.doc, &s.model.state(), 0, Vec3::new(23.0, 20.0, 2.5), true).unwrap_or_default();
|
||||
assert!(!wall.is_empty());
|
||||
for (name, z) in [("High", 4.0), ("Low", 1.0)] {
|
||||
let saved = json!({"name": name, "origin": {"occurrence": 0, "snap": {"face": wall, "pick": [23.0, 20.0, z], "type": "circle_center"}}});
|
||||
let o: solidcraft_doc::joints::NamedOrigin = serde_json::from_value(saved).unwrap_or_else(|e| panic!("{e}"));
|
||||
s.doc_mut().assembly.origins.push(o);
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-joint-end-tie-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("plate.json").to_string_lossy().to_string();
|
||||
run(&mut s, "file.save", json!({"path": path}));
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (top, bottom) = (|t: f64| (Vec3::new(20.0, 20.0, t), Vec3::Z), (Vec3::new(20.0, 20.0, 0.0), -Vec3::Z));
|
||||
// Where the pick is nearer one end, that end; midway along the wall (the plate 8 mm thick
|
||||
// for the pick 4 mm up, 2 mm for the one 1 mm up), the bottom end, as those builds took.
|
||||
for (t, high, low) in [(5.0, Some(top(5.0)), bottom), (8.0, Some(bottom), bottom), (2.0, None, bottom), (5.0, Some(top(5.0)), bottom)] {
|
||||
run(&mut s, "parameters.change", json!({"name": "T", "expression": format!("{t} mm")}));
|
||||
for (name, want) in [("High", high), ("Low", Some(low))] {
|
||||
let Some((o, z)) = want else { continue };
|
||||
let got = origin_frame(&s, name);
|
||||
assert!(got.is_some_and(|(go, gz)| near(go, o) && near(gz, z)), "T = {t}, {name}: {got:?}, expected {o:?} {z:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origins_on_a_body_placed_by_an_offset_agree_named_or_not() {
|
||||
// A plate made at the origin, then placed by an offset (turned a quarter about x, moved):
|
||||
// a body moved in from another component keeps its geometry where it was made.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 5, "corner": [0, 0, 0], "body_name": "Plate"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [20, 20, 5], "diameter": 6}));
|
||||
let offset = solidcraft_doc::rigid(Vec3::new(7.0, 0.0, 3.0), Vec3::ZERO, Vec3::X, std::f64::consts::FRAC_PI_2);
|
||||
s.doc_mut().body_offsets.insert("Plate".into(), offset);
|
||||
s.refresh();
|
||||
let at = |p: Vec3| solidcraft_doc::apply_point(&offset, p);
|
||||
let up = solidcraft_doc::apply_vector(&offset, Vec3::Z);
|
||||
let st = s.model.state();
|
||||
let (top_pick, wall_pick) = (at(Vec3::new(10.0, 10.0, 5.0)), at(Vec3::new(23.0, 20.0, 4.0)));
|
||||
let top = solidcraft_doc::joints::face_name_at(&s.doc, &st, 0, top_pick, false);
|
||||
let wall = solidcraft_doc::joints::face_name_at(&s.doc, &st, 0, wall_pick, true);
|
||||
assert!(top.is_some() && wall.is_some(), "{top:?} {wall:?}");
|
||||
let resolve = |snap: Snap| {
|
||||
let o = solidcraft_doc::joints::JointOrigin { occurrence: 0, snap };
|
||||
let m = solidcraft_doc::joints::resolve_origin(&s.doc, &st, &o).unwrap_or_else(|e| panic!("{e}"));
|
||||
(Vec3::new(m[3][0], m[3][1], m[3][2]), Vec3::new(m[2][0], m[2][1], m[2][2]))
|
||||
};
|
||||
// The top face's centre: by its name, and by the point alone.
|
||||
let named = resolve(Snap::FaceCenter { pick: top_pick, face: top, x: None });
|
||||
let unnamed = resolve(Snap::FaceCenter { pick: top_pick, face: None, x: None });
|
||||
assert!(near(named.0, unnamed.0) && near(named.1, unnamed.1), "{named:?} {unnamed:?}");
|
||||
assert!(near(named.1, up) && (named.0 - at(Vec3::new(0.0, 0.0, 5.0))).dot(up).abs() < 1e-6, "{named:?}");
|
||||
// The hole's top end (the pick nearer it): by the wall's name, and by the point alone.
|
||||
let named = resolve(Snap::CircleCenter { pick: wall_pick, face: wall, end: None, x: None });
|
||||
let unnamed = resolve(Snap::CircleCenter { pick: wall_pick, face: None, end: None, x: None });
|
||||
assert!(near(named.0, unnamed.0) && near(named.1, unnamed.1), "{named:?} {unnamed:?}");
|
||||
assert!(near(named.0, at(Vec3::new(20.0, 20.0, 5.0))) && near(named.1, up), "{named:?}");
|
||||
}
|
||||
@@ -55,17 +55,17 @@ fn circle_given_by_its_centre_takes_that_round_end() {
|
||||
for z in [6.0, 16.0] {
|
||||
assert!(near(place(&s, "Knob", Vec3::new(0.0, 0.0, z)), Vec3::new(0.0, 0.0, z)), "{:?}", place(&s, "Knob", Vec3::new(0.0, 0.0, z)));
|
||||
}
|
||||
// The top end's centre: its axis points up, out of the knob.
|
||||
// The top end's centre: its axis points up, out of the knob. It is kept by the knob's wall
|
||||
// and the face its end opens onto, so it follows them.
|
||||
run(&mut s, "joint.origin", json!({"occurrence": "Knob", "circle": [0, 0, 16], "name": "Top"}));
|
||||
let top = s.doc.assembly.origins.iter().find(|o| o.name == "Top").map(|o| o.origin.snap.clone());
|
||||
assert!(
|
||||
matches!(top, Some(solidcraft_doc::joints::Snap::Point { point, z: Some(z) }) if near(point, Vec3::new(0.0, 0.0, 16.0)) && near(z, Vec3::Z)),
|
||||
"{top:?}"
|
||||
);
|
||||
// Off the axis is still refused, and so is a coupling size (it follows a face).
|
||||
let top = s.doc.assembly.origins.iter().find(|o| o.name == "Top").map(|o| o.origin.clone()).unwrap();
|
||||
assert!(matches!(&top.snap, solidcraft_doc::joints::Snap::CircleCenter { face: Some(_), end: Some(_), .. }), "{top:?}");
|
||||
let m = solidcraft_doc::joints::resolve_origin(&s.doc, &s.model.state(), &top).unwrap();
|
||||
assert!(near(Vec3::new(m[3][0], m[3][1], m[3][2]), Vec3::new(0.0, 0.0, 16.0)) && near(Vec3::new(m[2][0], m[2][1], m[2][2]), Vec3::Z), "{m:?}");
|
||||
// Off the axis is still refused; a coupling size given by a centre is that round face's
|
||||
// radius (it follows the face).
|
||||
assert!(s.execute("joint.origin", &json!({"occurrence": "Knob", "circle": [3, 0, 6]})).is_err());
|
||||
let e = s.execute("joint.screw", &json!({"joint": "J", "pitch": {"occurrence": "Knob", "circle": [0, 0, 6]}})).unwrap_err();
|
||||
assert!(e.to_string().contains("not its centre"), "{e}");
|
||||
run(&mut s, "joint.screw", json!({"joint": "J", "pitch": {"occurrence": "Knob", "circle": [0, 0, 6]}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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());
|
||||
@@ -285,6 +287,13 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
|
||||
}
|
||||
|
||||
/// The id for a new item of a list: one past the largest id in it. Ids come from the design file,
|
||||
/// so the largest may be the largest a `u64` holds, and one past that is an error, not an overflow
|
||||
/// (a panic where overflow is checked, an id that is already taken where it is not).
|
||||
pub(crate) fn next_free_id(cmd: &str, ids: impl Iterator<Item = u64>) -> Result<u64> {
|
||||
ids.max().unwrap_or(0).checked_add(1).ok_or_else(|| crate::params::bad(cmd, "this design has no ids left to give"))
|
||||
}
|
||||
|
||||
/// The end of an unknown command's error: up to three ids like `id`, nearest first. Ids a few
|
||||
/// letters away (`solid.extrud` → `solid.extrude`) come before ids that end in the same word
|
||||
/// (`file.open` → `doc.open`). Empty when nothing is close.
|
||||
|
||||
@@ -95,10 +95,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
|
||||
const MAX_ITEMS: usize = 10_000;
|
||||
|
||||
fn next_id(s: &Session) -> u64 {
|
||||
fn next_id(s: &Session, cmd: &str) -> Result<u64> {
|
||||
let a = &s.doc.assembly;
|
||||
let ids = a.contact_sets.iter().map(|c| c.id).chain(a.studies.iter().map(|m| m.id)).chain(a.exploded.iter().map(|e| e.id));
|
||||
ids.max().unwrap_or(0) + 1
|
||||
super::next_free_id(cmd, ids)
|
||||
}
|
||||
|
||||
fn occurrences(s: &Session, p: &Value, cmd: &str) -> Result<Vec<u64>> {
|
||||
@@ -424,7 +424,7 @@ fn new_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc.assembly.contact_sets.len() >= MAX_ITEMS {
|
||||
return Err(bad(cmd, "too many contact sets"));
|
||||
}
|
||||
let id = next_id(s);
|
||||
let id = next_id(s, cmd)?;
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Contact Set{}", s.doc.assembly.contact_sets.len() + 1));
|
||||
let a = &mut s.doc_mut().assembly;
|
||||
a.contact_sets.push(ContactSet { id, name: name.clone(), occurrences: occ, suppressed: false });
|
||||
@@ -532,7 +532,7 @@ fn study(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
keys.push(StudyKey { joint, index, points });
|
||||
}
|
||||
}
|
||||
let id = next_id(s);
|
||||
let id = next_id(s, cmd)?;
|
||||
let count = s.doc.assembly.studies.len();
|
||||
let a = &mut s.doc_mut().assembly;
|
||||
let out = match existing.and_then(|i| a.studies.get_mut(i)) {
|
||||
@@ -952,7 +952,7 @@ fn explode_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc.assembly.exploded.len() >= MAX_ITEMS {
|
||||
return Err(bad(cmd, "too many exploded views"));
|
||||
}
|
||||
let id = next_id(s);
|
||||
let id = next_id(s, cmd)?;
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Exploded View{}", s.doc.assembly.exploded.len() + 1));
|
||||
s.doc_mut().assembly.exploded.push(ExplodedView { id, name: name.clone(), moves: moves.clone() });
|
||||
Ok(json!({"view": id, "name": name, "moves": moves.iter().map(|(o, t)| json!({"occurrence": o, "translate": t})).collect::<Vec<_>>()}))
|
||||
|
||||
@@ -12,7 +12,7 @@ const CMD: &str = "inspect.silhouette";
|
||||
const MAX_PIXELS: usize = 4 * 1024 * 1024;
|
||||
|
||||
// For agents comparing the model with a picture; nothing to show a person, so no toolbar button.
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", run).noundo().params(
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", run).icon("project").noundo().params(
|
||||
"view?: front|back|left|right|top|bottom|iso (default front); or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (mm, radians; fov 0 means parallel); bodies?: [world body names] (default visible bodies, named hidden bodies included); rows?: mask height in pixels (default 240, clamped to 16..1024) → tightly framed run-length mask, box_mm, mm_per_px, right/up, projection, body and triangle counts",
|
||||
)];
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! A Single hole at a sketch point follows the point when the sketch changes (#2110), drilled
|
||||
//! into the face it was placed on, and keeps doing so after the design is saved and reopened.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn solid_at(s: &Session, p: Vec3) -> bool {
|
||||
s.model.state().bodies.iter().any(|b| b.body.contains(p))
|
||||
}
|
||||
|
||||
/// The circle `c1`'s centre: its point id and where it is in the world.
|
||||
fn centre(s: &Session) -> (String, Vec3) {
|
||||
let st = s.model.state();
|
||||
let ss = st.sketches.first().unwrap();
|
||||
ss.sketch
|
||||
.curves
|
||||
.iter()
|
||||
.find_map(|c| match c.kind {
|
||||
solidcraft_sketch::CurveKind::Circle { c, .. } => Some((ss.sketch.points[c].id.clone(), ss.plane.to_world(ss.sketch.points[c].pos))),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The bore (5 mm across, 10 mm deep, down from the top face) is about `at`: empty on its axis
|
||||
/// and up to 2.4 mm from it, solid 2.6 mm from it in every direction.
|
||||
fn bore_at(s: &Session, at: Vec3) {
|
||||
for k in 0..8 {
|
||||
let a = PI / 4.0 * f64::from(k);
|
||||
let p = |r: f64| at + Vec3::new(a.cos(), a.sin(), 0.0) * r - Vec3::new(0.0, 0.0, 5.0);
|
||||
assert!(!solid_at(s, p(0.0)) && !solid_at(s, p(2.4)), "the bore about {at:?} is empty {a} rad from +x");
|
||||
assert!(solid_at(s, p(2.6)), "the wall about {at:?} is solid {a} rad from +x");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_hole_follows_its_sketch_point() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}, "name": "S"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [12, 9], "radius": 4, "id": "c1"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let (id, before) = centre(&s);
|
||||
assert!((before.z - 20.0).abs() < 1e-9, "the centre is on the top face: {before:?}");
|
||||
let block = volume(&mut s);
|
||||
run(&mut s, "solid.hole", json!({"sketch": "S", "points": [id], "single": true, "diameter": 5, "depth": 10, "name": "H"}));
|
||||
let bore = PI * 2.5 * 2.5 * 10.0;
|
||||
assert!((block - volume(&mut s) - bore).abs() < 1e-3 * bore, "one 5 x 10 bore: {} of {bore}", block - volume(&mut s));
|
||||
bore_at(&s, before);
|
||||
|
||||
// Move the centre 8 mm along the sketch's first axis: the hole goes with it.
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
|
||||
run(&mut s, "sketch.move_point", json!({"point": "c1.center", "to": [20, 9]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let (_, after) = centre(&s);
|
||||
assert!((after.dist(before) - 8.0).abs() < 1e-6, "the centre moved 8 mm: {before:?} -> {after:?}");
|
||||
bore_at(&s, after);
|
||||
assert!(solid_at(&s, before - Vec3::new(0.0, 0.0, 5.0)), "the old spot is filled again");
|
||||
|
||||
// Saved and reopened, the hole is still a Single one at the point.
|
||||
let mut t = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
t.refresh();
|
||||
bore_at(&t, after);
|
||||
let f = t.doc.find_feature("H").unwrap();
|
||||
assert!(matches!(&f.kind, solidcraft_doc::FeatureKind::Hole { points: Some(sp), .. } if sp.single && sp.ids == [id.clone()]), "{:?}", f.kind);
|
||||
assert!((block - volume(&mut t) - bore).abs() < 1e-3 * bore);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_hole_takes_one_point_and_no_flip() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}, "name": "S"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [12, 9], "radius": 4, "id": "c1"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [28, 9], "radius": 4, "id": "c2"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let (id, _) = centre(&s);
|
||||
run(&mut s, "solid.hole", json!({"sketch": "S", "points": [id.clone()], "single": true, "diameter": 5}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
for p in
|
||||
[json!({"sketch": "S", "single": true, "diameter": 5}), json!({"sketch": "S", "points": [id], "single": true, "flip": true, "diameter": 5})]
|
||||
{
|
||||
assert!(s.execute("solid.hole", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
|
||||
// It belongs to the active component (its plane is in that frame) and moves with it.
|
||||
let component = s.active_component;
|
||||
let doc = s.doc_mut();
|
||||
let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1;
|
||||
let id = super::next_free_id(cmd, doc.canvases.iter().map(|c| c.id))?;
|
||||
let base = if decal { "Decal" } else { "Canvas" };
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("{base}{id}"));
|
||||
doc.canvases.push(Canvas {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user