Compare commits
27
Commits
sketch-grid
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -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,3 +1,5 @@
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
+18
-13
@@ -19,7 +19,7 @@
|
||||
# of this repository (people with write access; fork pull requests never run on this machine).
|
||||
# There, Select checks and ci run on the self-hosted light runner (`self-hosted-light`, quick
|
||||
# jobs, so they never wait behind a build), and the repository checks, Clippy, the doc tests,
|
||||
# the reference tests and test shards 1-2 on the self-hosted runners (`self-hosted-fast`, warm
|
||||
# the reference tests and all four test shards on the self-hosted runners (`self-hosted-fast`, warm
|
||||
# build directories via scripts/ci/warm-target.sh). Fork PRs' Linux jobs run on the isolated, ephemeral
|
||||
# `self-hosted-untrusted` runners (no warm caches); everything else runs on GitHub's runners.
|
||||
# The test archive is always built on GitHub's runners: the self-hosted machine has a newer
|
||||
@@ -186,8 +186,9 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
# 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: repo-checks
|
||||
@@ -210,8 +211,8 @@ jobs:
|
||||
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
|
||||
@@ -227,8 +228,9 @@ jobs:
|
||||
- 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
|
||||
@@ -248,7 +250,8 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 45
|
||||
# Fork builds compile inside gVisor on shared boxes; give them room to finish and upload.
|
||||
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 90 || 45 }}
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
@@ -290,7 +293,7 @@ jobs:
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: [changes, build-tests]
|
||||
runs-on: ${{ (needs.changes.outputs.local == 'true' && matrix.shard <= 2) && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -451,8 +454,9 @@ jobs:
|
||||
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
|
||||
@@ -501,8 +505,9 @@ jobs:
|
||||
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
|
||||
# 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: oracle
|
||||
|
||||
@@ -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,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 });
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
|
||||
+2
-1
@@ -93,6 +93,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/gl.json | Pablo López (pablopeza) | Galician translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
@@ -462,7 +463,7 @@ copied into SolidCraft.
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
|
||||
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters (vendor/README.md) |
|
||||
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters; surface second derivatives (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
## Vendored renderer compatibility fix
|
||||
|
||||
@@ -129,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`)
|
||||
|
||||
+30
-4
@@ -258,6 +258,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.
|
||||
@@ -267,10 +271,11 @@ and a fresh CI run:
|
||||
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
|
||||
the expected ones).
|
||||
|
||||
Comment `/claim` on an issue to take it: you are assigned, it gets the `in progress` label, and
|
||||
the bot replies with the deadline. Open a draft PR referencing the issue within 72 hours, or the
|
||||
claim lapses and the issue is freed again; a claim with a PR is freed after 14 days without
|
||||
pushes. `/unclaim` releases it early (only you or a maintainer can). Issues a maintainer has
|
||||
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.
|
||||
|
||||
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
|
||||
@@ -299,6 +304,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).
|
||||
|
||||
@@ -289,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).
|
||||
|
||||
+6
-1
@@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
@@ -73,6 +73,11 @@ hierarchies are shared; bounded desktop preparation and occlusion use up to eigh
|
||||
Curves are approximated; drawing sheets,
|
||||
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
|
||||
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
|
||||
packages. The first geometry increment defines validated section records in a plane-local
|
||||
frame; the ledger separates that contract from section producers and the Drawing workspace.
|
||||
|
||||
Desktop view DXF export runs in the file-operation worker with five-stage progress and
|
||||
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
|
||||
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
|
||||
|
||||
@@ -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}}));
|
||||
|
||||
@@ -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",
|
||||
@@ -864,6 +866,7 @@
|
||||
"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_edit": "Edit",
|
||||
@@ -885,10 +888,12 @@
|
||||
"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_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",
|
||||
@@ -1367,6 +1372,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",
|
||||
|
||||
+74
-14
@@ -7,6 +7,7 @@
|
||||
"A new configuration, a copy of the active one": "Una configuración nueva, copia de la activa",
|
||||
"AI": "AI",
|
||||
"About SolidCraft": "Acerca de SolidCraft",
|
||||
"Acceleration": "Aceleración",
|
||||
"Active": "Activo",
|
||||
"Active rule": "Regla activa",
|
||||
"Add": "Añadir",
|
||||
@@ -14,8 +15,10 @@
|
||||
"Add Mid Radius": "Añadir radio intermedio",
|
||||
"Add Row": "Añadir fila",
|
||||
"Add condition": "Añadir condición",
|
||||
"Against": "Contra",
|
||||
"Alignment": "Alineación",
|
||||
"All Sketch Points": "Todos los puntos del boceto",
|
||||
"All components": "Todos los componentes",
|
||||
"All open designs": "Todos los diseños abiertos",
|
||||
"Allowed expressions, separated by semicolons": "Expresiones permitidas, separadas por punto y coma",
|
||||
"Also deletes": "También elimina",
|
||||
@@ -57,6 +60,7 @@
|
||||
"Configuration rules": "Reglas de configuración",
|
||||
"Construction": "Construcción",
|
||||
"Contact": "Contacto",
|
||||
"Contact sets": "Conjuntos de contacto",
|
||||
"Continuity": "Continuidad",
|
||||
"Copy Error Message": "Copiar mensaje de error",
|
||||
"Copy Warning Message": "Copiar mensaje de advertencia",
|
||||
@@ -85,14 +89,18 @@
|
||||
"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…",
|
||||
@@ -151,6 +159,8 @@
|
||||
"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.",
|
||||
@@ -170,16 +180,20 @@
|
||||
"Per Edge": "Por arista",
|
||||
"Pick the tool bodies": "Selecciona los cuerpos herramienta",
|
||||
"Picked": "Seleccionado",
|
||||
"Picked point": "Punto seleccionado",
|
||||
"Place at the origin instead": "Colocar en el origen",
|
||||
"Playback": "Reproducción",
|
||||
"Plot": "Graficar",
|
||||
"Position": "Posición",
|
||||
"Position (0 to 1)": "Posición (de 0 a 1)",
|
||||
"Position (G0)": "Posición (G0)",
|
||||
"Position along the edge, 0 at the start end, 1 at the other": "Posición a lo largo de la arista: 0 en el extremo inicial y 1 en el otro",
|
||||
"Preferences › Design turns the offer back on": "Preferencias › Diseño vuelve a activar la oferta",
|
||||
"Preview Row": "Previsualizar fila",
|
||||
"Preview leaves the current design unchanged.": "La vista previa no modifica el diseño actual.",
|
||||
"Profiles/Curves": "Perfiles/curvas",
|
||||
"Projection": "Proyección",
|
||||
"Quantity": "Cantidad",
|
||||
"RECENT": "RECIENTES",
|
||||
"RULED SURFACE": "SUPERFICIE REGLADA",
|
||||
"Radius Fit": "Ajuste de radio",
|
||||
@@ -210,10 +224,11 @@
|
||||
"Revert Position": "Revertir posición",
|
||||
"Review the report and screenshot before opening GitHub.": "Revisa el informe y la captura antes de abrir GitHub.",
|
||||
"Rim Edge": "Arista del borde",
|
||||
"Roll History Marker Here": "Desplazar marcador del historial hasta aquí",
|
||||
"Roll History Marker Here": "Desplazar marcador de la línea de tiempo hasta aquí",
|
||||
"Round": "Redondo",
|
||||
"Rule": "Regla",
|
||||
"Save Rule": "Guardar regla",
|
||||
"Save the study as an animated GIF, seen as the viewport shows it": "Guardar el estudio como GIF animado, tal como se ve en el visor",
|
||||
"Saved preview. Geometry is being checked before editing.": "Vista previa guardada. Se está comprobando la geometría antes de editarla.",
|
||||
"Scale factor": "Factor de escala",
|
||||
"Search": "Buscar",
|
||||
@@ -244,6 +259,7 @@
|
||||
"Stand this part up?": "¿Poner esta pieza en vertical?",
|
||||
"Step": "Paso",
|
||||
"Steps": "Pasos",
|
||||
"Study": "Estudio",
|
||||
"Style": "Estilo",
|
||||
"Suppressed": "Suprimido",
|
||||
"Swap Ends": "Intercambiar extremos",
|
||||
@@ -263,6 +279,7 @@
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara no permite una vinculación persistente. Elige otra cara o utiliza expresamente un plano fijo.",
|
||||
"To Offset": "Hasta desfase",
|
||||
"Tolerance": "Tolerancia",
|
||||
"Too close": "Demasiado cerca",
|
||||
"Turn off section analysis before exporting the view.": "Desactiva el análisis de sección antes de exportar la vista.",
|
||||
"Type": "Tipo",
|
||||
"UNTRIM": "ANULAR RECORTE",
|
||||
@@ -273,6 +290,7 @@
|
||||
"Use selected face as fixed plane": "Usar la cara seleccionada como plano fijo",
|
||||
"VIEW": "VISTA",
|
||||
"Value": "Valor",
|
||||
"Velocity": "Velocidad",
|
||||
"Vertices": "Vértices",
|
||||
"View navigator": "Navegador de vistas",
|
||||
"Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Solo cuerpos visibles. Las curvas utilizan segmentos rectos (tolerancia de 0,01 mm).",
|
||||
@@ -296,6 +314,13 @@
|
||||
"browser.precision": "Precisión: {precision}",
|
||||
"browser.sketch_name": "Boceto{number}",
|
||||
"browser.units": "Unidades: {units}",
|
||||
"cam.flat": "Cara plana",
|
||||
"cam.follower": "Seguidor",
|
||||
"cam.knife": "Arista de cuchilla",
|
||||
"cam.needs_joints": "necesita una unión que gire y otra que deslice o bascule",
|
||||
"cam.roller": "Rodillo",
|
||||
"cam.roller_pick": "Rodillo (opcional)",
|
||||
"cam.tip_pick": "Punta (opcional)",
|
||||
"cmd.appearance_assign": "Apariencia",
|
||||
"cmd.appearance_library": "Biblioteca de apariencias",
|
||||
"cmd.appearance_list": "Listar apariencias",
|
||||
@@ -388,6 +413,7 @@
|
||||
"cmd.file_slicers": "Laminadores instalados",
|
||||
"cmd.inspect_accessibility": "Análisis de accesibilidad",
|
||||
"cmd.inspect_center_of_mass": "Centro de masas",
|
||||
"cmd.inspect_clearance": "Holgura",
|
||||
"cmd.inspect_compare": "Comparar cuerpos",
|
||||
"cmd.inspect_continuity": "Análisis de continuidad",
|
||||
"cmd.inspect_curvature_comb": "Análisis de peine de curvatura",
|
||||
@@ -397,8 +423,10 @@
|
||||
"cmd.inspect_interference": "Interferencia",
|
||||
"cmd.inspect_isocurve": "Análisis de isocurvas",
|
||||
"cmd.inspect_minimum_radius": "Análisis de radio mínimo",
|
||||
"cmd.inspect_silhouette": "Silueta",
|
||||
"cmd.inspect_zebra": "Análisis de franjas cebra",
|
||||
"cmd.joint_as_built": "Unión según posición actual",
|
||||
"cmd.joint_cam": "Leva",
|
||||
"cmd.joint_create": "Unión",
|
||||
"cmd.joint_delete": "Eliminar unión",
|
||||
"cmd.joint_drive": "Accionar uniones",
|
||||
@@ -429,8 +457,11 @@
|
||||
"cmd.model_threads": "Listar roscas",
|
||||
"cmd.motion_delete": "Eliminar estudio de movimiento",
|
||||
"cmd.motion_export": "Exportar estudio de movimiento",
|
||||
"cmd.motion_export_animation": "Exportar animación del movimiento",
|
||||
"cmd.motion_interference": "Interferencia de movimiento",
|
||||
"cmd.motion_list": "Listar estudios de movimiento",
|
||||
"cmd.motion_play": "Reproducir estudio de movimiento",
|
||||
"cmd.motion_plot": "Graficar estudio de movimiento",
|
||||
"cmd.motion_study": "Estudio de movimiento",
|
||||
"cmd.motion_trace": "Trayectoria de movimiento",
|
||||
"cmd.motion_trace_keep": "Conservar trayectoria de movimiento",
|
||||
@@ -456,7 +487,7 @@
|
||||
"cmd.parts_library": "Piezas normalizadas",
|
||||
"cmd.parts_resize": "Cambiar tamaño de pieza",
|
||||
"cmd.plastic_assign_rule": "Asignar regla de plástico",
|
||||
"cmd.plastic_boss": "Resalte",
|
||||
"cmd.plastic_boss": "Saliente",
|
||||
"cmd.plastic_lip": "Labio",
|
||||
"cmd.plastic_manage_rules": "Gestionar reglas de plástico",
|
||||
"cmd.plastic_rest": "Apoyo",
|
||||
@@ -548,7 +579,7 @@
|
||||
"cmd.sketch_slot_center_point": "Ranura por punto central",
|
||||
"cmd.sketch_slot_center_to_center": "Ranura entre centros",
|
||||
"cmd.sketch_slot_overall": "Ranura por longitud total",
|
||||
"cmd.sketch_snap": "Detección de referencias",
|
||||
"cmd.sketch_snap": "Referencia a objetos",
|
||||
"cmd.sketch_solve": "Resolver boceto",
|
||||
"cmd.sketch_spline3d": "Spline 3D",
|
||||
"cmd.sketch_spline_control_point": "Spline por puntos de control",
|
||||
@@ -573,7 +604,7 @@
|
||||
"cmd.surface_extrude": "Extruir superficie",
|
||||
"cmd.surface_fair": "Suavizar superficie",
|
||||
"cmd.surface_fillet": "Redondeo de superficie",
|
||||
"cmd.surface_loft": "Transición de superficies",
|
||||
"cmd.surface_loft": "Solevación de superficies",
|
||||
"cmd.surface_match": "Igualar superficie",
|
||||
"cmd.surface_offset": "Desfasar superficie",
|
||||
"cmd.surface_patch": "Parche",
|
||||
@@ -595,7 +626,7 @@
|
||||
"cmd.timeline_redefine": "Editar operación",
|
||||
"cmd.timeline_rename": "Cambiar nombre",
|
||||
"cmd.timeline_reorder": "Reordenar operación",
|
||||
"cmd.timeline_roll_to": "Desplazar marcador del historial",
|
||||
"cmd.timeline_roll_to": "Desplazar marcador de la línea de tiempo",
|
||||
"cmd.timeline_rollback": "Mover marcador de la línea de tiempo",
|
||||
"cmd.timeline_suppress": "Suprimir operación",
|
||||
"cmd.view_apply": "Restaurar vista guardada",
|
||||
@@ -634,6 +665,18 @@
|
||||
"drive.stopped_by_contact": "Detenido en {value}: {a} atravesaría {b}",
|
||||
"drive.stopped_by_loop": "Detenido en {value}: el bucle que pasa por {joint} no puede cerrarse más",
|
||||
"drive.unconnected": "No mueve nada: {part} no está conectado a ningún elemento fijado",
|
||||
"dxf.cancel_wait": "Esperando a que se detenga la exportación…",
|
||||
"dxf.cancelled": "Exportación DXF cancelada",
|
||||
"dxf.cancelling": "Cancelando la exportación DXF",
|
||||
"dxf.exporting": "Exportando DXF",
|
||||
"dxf.invalid_tolerance": "La tolerancia de curva debe estar entre 0,0001 y 1 mm.",
|
||||
"dxf.progress.index": "Preparando superficies (2/5)",
|
||||
"dxf.progress.meshes": "Preparando mallas (1/5)",
|
||||
"dxf.progress.occlusion": "Resolviendo líneas ocultas (4/5)",
|
||||
"dxf.progress.projection": "Proyectando geometría (3/5)",
|
||||
"dxf.progress.write": "Escribiendo DXF (5/5)",
|
||||
"dxf.tolerance": "Tolerancia de curva",
|
||||
"dxf.tolerance_help": "Solo cuerpos visibles. Las curvas usan segmentos rectos. Una tolerancia menor da curvas más finas y consume más tiempo y memoria.",
|
||||
"expression, e.g. 25 mm": "expresión, p. ej., 25 mm",
|
||||
"fits the face, e.g. M8": "se ajusta a la cara, p. ej., M8",
|
||||
"from the rule": "de la regla",
|
||||
@@ -665,6 +708,10 @@
|
||||
"joint.from_bodies_hint": "Cada cuerpo seleccionado pasa a un componente nuevo propio y conserva su posición.",
|
||||
"joint.one_body": "Las dos selecciones pertenecen a {body}. Selecciona la segunda en otra pieza.",
|
||||
"joint.one_component": "Estas piezas son cuerpos de un mismo componente: {bodies}. Una unión mueve un componente respecto a otro, por lo que cada pieza debe tener su propio componente.",
|
||||
"motion.checked_to": "Comprobado solo hasta el paso {step}: la comprobación agotó su presupuesto de trabajo",
|
||||
"motion.collision": "{a} · {b}: pasos {steps}",
|
||||
"motion.duration_hint": "segundos (vacío: por paso)",
|
||||
"motion.no_collisions": "Sin colisiones durante el estudio",
|
||||
"nav.touchpad.help": "Mantén pulsado Mayús y mueve el puntero para encuadrar; mantén Alt/Opción y muévelo para orbitar. No hace falta pulsar ningún botón del ratón. El desplazamiento vertical hace zoom.",
|
||||
"nav.touchpad.label": "Panel táctil",
|
||||
"nav.touchpad.orbit": "Orbitar (mantén Alt/Opción y mueve el puntero; sin pulsar botones)",
|
||||
@@ -727,17 +774,17 @@
|
||||
"render.use_preview": "Usar vista previa integrada",
|
||||
"sample.bracket.about": "Brida base con pestañas laterales y reborde, según una regla de 2 mm",
|
||||
"sample.bracket.name": "Soporte de chapa",
|
||||
"sample.enclosure.about": "Caja vaciada con labio, alojamientos para tornillos, apoyo y cierre a presión",
|
||||
"sample.enclosure.about": "Caja vaciada con labio, salientes para tornillos, apoyo y encaje a presión",
|
||||
"sample.enclosure.name": "Carcasa de plástico",
|
||||
"sample.flange.about": "Cubo y brida con patrón circular controlado por un parámetro",
|
||||
"sample.flange.name": "Brida atornillada",
|
||||
"sample.hinge.about": "Dos componentes y una unión de revolución accionada hasta 60°",
|
||||
"sample.hinge.name": "Conjunto de bisagra",
|
||||
"sample.plate.about": "Placa paramétrica con redondeos, agujeros y un resalte perforado",
|
||||
"sample.plate.about": "Placa paramétrica con redondeos, agujeros y un saliente perforado",
|
||||
"sample.plate.name": "Placa de ejemplo",
|
||||
"sample.vents.about": "Ranuras en un patrón rectangular: las filas, las columnas y el paso son parámetros",
|
||||
"sample.vents.name": "Placa de ventilación",
|
||||
"settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia la vista, nunca el diseño ni las exportaciones.",
|
||||
"settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia el visor, nunca el diseño ni las exportaciones.",
|
||||
"settings.frame_rate_note": "Muestra los fotogramas por segundo en la esquina inferior derecha de la vista. Mientras esté activado, la vista se redibuja continuamente.",
|
||||
"settings.measure_axis_distances": "Mostrar distancias X/Y/Z",
|
||||
"settings.measure_axis_distances_help": "Mostrar las cotas de cada eje global junto a la distancia total mientras está abierta la herramienta Medir.",
|
||||
@@ -763,6 +810,10 @@
|
||||
"sketch_text.path_hint": "Curva del boceto que seguirá el texto: haz clic aquí y después en la curva",
|
||||
"sketch_text.use_font_file": "Usar el archivo de fuente {path}",
|
||||
"the parts library is not available": "la biblioteca de piezas no está disponible",
|
||||
"thread.automatic_size": "Tamaño automático",
|
||||
"thread.designation": "Designación",
|
||||
"thread.iso_metric": "Métrica ISO",
|
||||
"thread.select_size": "Seleccionar tamaño",
|
||||
"timeline.group": "Nueva carpeta de operaciones",
|
||||
"timeline.move_many": "Mover elementos de la línea de tiempo",
|
||||
"timeline.move_to_group": "Mover operaciones a la carpeta",
|
||||
@@ -1008,6 +1059,9 @@
|
||||
"ui.construct": "Construir",
|
||||
"ui.construction": "Construcción",
|
||||
"ui.context_menu": "Menú contextual",
|
||||
"ui.continuity_g0": "G0 arista",
|
||||
"ui.continuity_g1": "G1 tangencia",
|
||||
"ui.continuity_g2": "G2 curvatura",
|
||||
"ui.copy": "Copiar",
|
||||
"ui.corner": "Esquina",
|
||||
"ui.corners": "Esquinas",
|
||||
@@ -1069,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",
|
||||
@@ -1095,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",
|
||||
@@ -1121,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",
|
||||
@@ -1191,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",
|
||||
@@ -1244,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",
|
||||
@@ -1289,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",
|
||||
@@ -1309,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…",
|
||||
@@ -1354,7 +1413,7 @@
|
||||
"ui.smooth": "Suave",
|
||||
"ui.smooth_wheel_zoom": "Zoom suave con la rueda",
|
||||
"ui.smooth_wheel_zoom_tip": "Desactívalo para aplicar cada movimiento de la rueda inmediatamente, sin suavizado posterior",
|
||||
"ui.snap": "Ajustar a referencias",
|
||||
"ui.snap": "Referencia a objetos",
|
||||
"ui.space_mouse_hint": "Desplaza y gira la vista a la vez; los bocetos solo permiten desplazamiento y zoom.",
|
||||
"ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse",
|
||||
"ui.spacing": "Espaciado",
|
||||
@@ -1365,6 +1424,7 @@
|
||||
"ui.stand_up_tip": "Tras abrir un archivo STEP o IGES con Z hacia arriba, ofrecer un cuarto de vuelta alrededor de X",
|
||||
"ui.start": "Iniciar",
|
||||
"ui.start_radius": "Radio inicial",
|
||||
"ui.stretch": "Estirar",
|
||||
"ui.supersampling": "Supermuestreo (SSAA)",
|
||||
"ui.supersampling_adaptive": "Solo cuando la vista está inmóvil",
|
||||
"ui.supersampling_off": "Desactivado (1×)",
|
||||
|
||||
@@ -99,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…",
|
||||
@@ -158,6 +159,7 @@
|
||||
"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",
|
||||
@@ -277,6 +279,7 @@
|
||||
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Cette face ne peut pas fournir de rattachement durable. Choisissez une autre face ou utilisez explicitement un plan fixe.",
|
||||
"To Offset": "Jusqu'au décalage",
|
||||
"Tolerance": "Tolérance",
|
||||
"Too close": "Trop proches",
|
||||
"Turn off section analysis before exporting the view.": "Désactivez l'analyse de coupe avant d'exporter la vue.",
|
||||
"Type": "Type",
|
||||
"UNTRIM": "ANNULER L'AJUSTEMENT",
|
||||
@@ -410,6 +413,7 @@
|
||||
"cmd.file_slicers": "Slicers installés",
|
||||
"cmd.inspect_accessibility": "Analyse d'accessibilité",
|
||||
"cmd.inspect_center_of_mass": "Centre de gravité",
|
||||
"cmd.inspect_clearance": "Jeu",
|
||||
"cmd.inspect_compare": "Comparer les corps",
|
||||
"cmd.inspect_continuity": "Analyse de continuité",
|
||||
"cmd.inspect_curvature_comb": "Analyse par peigne de courbure",
|
||||
@@ -419,6 +423,7 @@
|
||||
"cmd.inspect_interference": "Interférence",
|
||||
"cmd.inspect_isocurve": "Analyse des isocourbes",
|
||||
"cmd.inspect_minimum_radius": "Analyse du rayon minimal",
|
||||
"cmd.inspect_silhouette": "Silhouette",
|
||||
"cmd.inspect_zebra": "Analyse zébrée",
|
||||
"cmd.joint_as_built": "Liaison telle que construite",
|
||||
"cmd.joint_cam": "Came",
|
||||
@@ -1054,6 +1059,9 @@
|
||||
"ui.construct": "Construire",
|
||||
"ui.construction": "Construction",
|
||||
"ui.context_menu": "Menu contextuel",
|
||||
"ui.continuity_g0": "G0 angle vif",
|
||||
"ui.continuity_g1": "G1 tangence",
|
||||
"ui.continuity_g2": "G2 courbure",
|
||||
"ui.copy": "Copier",
|
||||
"ui.corner": "Angle",
|
||||
"ui.corners": "Angles",
|
||||
@@ -1141,6 +1149,8 @@
|
||||
"ui.fully_constrained": "Entièrement contrainte",
|
||||
"ui.general": "Général",
|
||||
"ui.graphics": "Graphisme",
|
||||
"ui.grid_follows_view": "La grille suit la vue",
|
||||
"ui.grid_follows_view_hint": "Dans les vues de côté, la grille s'affiche sur le plan vertical qui leur fait face plutôt que sur le sol",
|
||||
"ui.ground_shadow": "Ombre au sol",
|
||||
"ui.groupSelected": "Grouper la sélection",
|
||||
"ui.heading_add_ins": "COMPLÉMENTS",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,7 @@
|
||||
"Arc length": "円弧の長さ",
|
||||
"Area": "表面積",
|
||||
"Attachment": "アタッチメント",
|
||||
"BOUNDARY SURFACE": "境界サーフェス",
|
||||
"BROWSER": "ブラウザ",
|
||||
"Bend Line Position": "曲げ線位置",
|
||||
"Bend Position": "曲げ位置",
|
||||
@@ -73,6 +74,7 @@
|
||||
"Counterbore depth": "座ぐり深さ",
|
||||
"Countersink angle": "皿穴角度",
|
||||
"Create Issue — review draft": "Issueを作成 — 下書きの確認",
|
||||
"Cross curves": "交差する曲線",
|
||||
"Cube": "キューブ",
|
||||
"Current orthographic view (1:1 mm)": "現在の平行投影(実寸・mm)",
|
||||
"Current perspective view": "現在の透視投影",
|
||||
@@ -864,6 +866,7 @@
|
||||
"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_edit": "編集",
|
||||
@@ -885,10 +888,12 @@
|
||||
"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_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": "待機に追加",
|
||||
@@ -1367,6 +1372,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": "右側面",
|
||||
|
||||
@@ -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": "当前透视视图",
|
||||
@@ -864,6 +866,7 @@
|
||||
"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_edit": "编辑",
|
||||
@@ -885,10 +888,12 @@
|
||||
"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_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": "加入队列",
|
||||
@@ -1367,6 +1372,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": "右视",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1465,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
|
||||
@@ -1692,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)]
|
||||
@@ -1733,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]
|
||||
}
|
||||
@@ -2088,7 +2125,7 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature",
|
||||
FeatureKind::SurfaceLoft { .. } => "SurfaceLoftFeature",
|
||||
FeatureKind::SurfaceBlend { .. } => "BlendFeature",
|
||||
FeatureKind::SurfaceBoundary { .. } => "BoundaryFeature",
|
||||
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "BoundaryFeature",
|
||||
FeatureKind::SurfaceMatch { .. } => "MatchFeature",
|
||||
FeatureKind::SurfaceFair { .. } => "FairFeature",
|
||||
FeatureKind::Stitch { .. } => "StitchFeature",
|
||||
@@ -2168,7 +2205,7 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceSweep { .. } => "SurfaceSweep",
|
||||
FeatureKind::SurfaceLoft { .. } => "SurfaceLoft",
|
||||
FeatureKind::SurfaceBlend { .. } => "Blend",
|
||||
FeatureKind::SurfaceBoundary { .. } => "Boundary",
|
||||
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "Boundary",
|
||||
FeatureKind::SurfaceMatch { .. } => "Match",
|
||||
FeatureKind::SurfaceFair { .. } => "Fair",
|
||||
FeatureKind::Stitch { .. } => "Stitch",
|
||||
|
||||
+44
-15
@@ -1035,6 +1035,20 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
|
||||
}
|
||||
return hash.finish();
|
||||
}
|
||||
// A network through 3D sketch curves reads their points through the components the
|
||||
// feature and those sketches are placed in, as a sweep's wired path does.
|
||||
if let FeatureKind::SurfaceNetwork { u, v, .. } = &f.kind {
|
||||
let sketches: Vec<crate::SketchId> = u.iter().chain(v).filter_map(|c| c.sketch).collect();
|
||||
if sketches.iter().any(wired) {
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
doc.component_transform(f.component).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||||
for sketch in &sketches {
|
||||
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
|
||||
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||||
}
|
||||
return hash.finish();
|
||||
}
|
||||
}
|
||||
// To / From Object finds its target where that target's component is placed (#928).
|
||||
let to_object = match &f.kind {
|
||||
FeatureKind::Extrude { extent, .. } => extent.to.is_some() || extent.from.is_some(),
|
||||
@@ -1750,8 +1764,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 };
|
||||
@@ -1779,16 +1794,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;
|
||||
}
|
||||
@@ -3065,7 +3083,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());
|
||||
}
|
||||
@@ -3176,21 +3195,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()
|
||||
@@ -3214,9 +3242,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.
|
||||
@@ -4691,6 +4719,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 { .. }
|
||||
|
||||
@@ -151,12 +151,49 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
|
||||
let body = kernel::boundary_patch(&boundaries, *crown, *samples)?;
|
||||
// Two curves are the two sides of a bridge. Three or four run round a loop: the Coons
|
||||
// patch of the loop, which lies in the sketch's plane with them, so every order holds.
|
||||
let body = if curves.len() >= 3 {
|
||||
if *crown != 0.0 {
|
||||
return Err(DocError::Invalid(
|
||||
"not supported yet: crown on a patch through three or four curves (it is the flat patch of its sides)".into(),
|
||||
));
|
||||
}
|
||||
loop_patch(&ss, curves)?
|
||||
} else {
|
||||
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
|
||||
kernel::boundary_patch(&boundaries, *crown, *samples)?
|
||||
};
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, body, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceNetwork { u, v, operation } => {
|
||||
if !matches!(operation, crate::Operation::NewBody | crate::Operation::Join) {
|
||||
return Err(DocError::Invalid("a boundary surface is a new body or joins the body of its edges".into()));
|
||||
}
|
||||
let us = network_sources(doc, st, f, u, "U")?;
|
||||
let vs = network_sources(doc, st, f, v, "V")?;
|
||||
let made = kernel::boundary_surface(&network_curves(&us), &network_curves(&vs))?;
|
||||
if *operation == crate::Operation::Join {
|
||||
// Into the body of the first curve picked on a body's edges.
|
||||
let target = u
|
||||
.iter()
|
||||
.chain(v)
|
||||
.find_map(|c| c.body.clone())
|
||||
.ok_or_else(|| DocError::Invalid("join needs a curve on a body's edges: the surface is sewn into that body".into()))?;
|
||||
let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Unknown(format!("body `{target}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let joined = kernel::boundary_join(&mb.body, &made)?;
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, joined, mb.feature);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, made, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceMatch { body, faces, target, edges, order } => {
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
@@ -439,6 +476,135 @@ fn push_sheet(st: &mut ModelState, f: &Feature, traced: crate::lineage::Traced)
|
||||
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
|
||||
}
|
||||
|
||||
/// The most curves one direction of a network may list (the kernel's own cap).
|
||||
const MAX_NETWORK_CURVES: usize = solidcraft_geom::network::MAX_NETWORK_CURVES;
|
||||
|
||||
/// A loop of three or four sketch curves as one patch: four give the Coons patch of opposite pairs
|
||||
/// (the first and third one way, the second and fourth the other); three collapse the side
|
||||
/// opposite the first curve to the corner where the other two meet.
|
||||
fn loop_patch(ss: &SolvedSketch, curves: &[String]) -> Result<kernel::Body> {
|
||||
let mut chains: Vec<Vec<Seg2>> = Vec::with_capacity(curves.len());
|
||||
for id in curves {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("sketch curve `{id}`")))?;
|
||||
let segs = ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci));
|
||||
if segs.is_empty() {
|
||||
return Err(DocError::Invalid(format!("curve `{id}` has no geometry to build on")));
|
||||
}
|
||||
chains.push(segs);
|
||||
}
|
||||
let side = |k: usize| -> Result<kernel::BoundaryCurve<'static>> {
|
||||
let (Some(id), Some(segs)) = (curves.get(k), chains.get(k)) else { return Err(DocError::Invalid("a boundary curve is missing".into())) };
|
||||
Ok(kernel::BoundaryCurve {
|
||||
label: format!("curve `{id}`"),
|
||||
source: kernel::CurveSource::Sketch { plane: ss.plane, segs: segs.clone() },
|
||||
continuity: kernel::Continuity::G0,
|
||||
})
|
||||
};
|
||||
match curves.len() {
|
||||
4 => Ok(kernel::boundary_surface(&[side(0)?, side(2)?], &[side(1)?, side(3)?])?),
|
||||
3 => {
|
||||
// The corner the second and third curves share, whichever way each was drawn.
|
||||
let ends = |k: usize| -> Option<[Vec2; 2]> {
|
||||
let c = chains.get(k)?;
|
||||
Some([c.first()?.start(), c.last()?.end()])
|
||||
};
|
||||
let (Some(b), Some(c)) = (ends(1), ends(2)) else { return Err(DocError::Invalid("a boundary curve is missing".into())) };
|
||||
let mut best = (f64::INFINITY, Vec2::new(0.0, 0.0));
|
||||
for p in b {
|
||||
for q in c {
|
||||
if p.dist(q) < best.0 {
|
||||
best = (p.dist(q), (p + q) * 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
let apex = kernel::BoundaryCurve {
|
||||
label: "the corner opposite the first curve".into(),
|
||||
source: kernel::CurveSource::Point(ss.plane.to_world(best.1)),
|
||||
continuity: kernel::Continuity::G0,
|
||||
};
|
||||
Ok(kernel::boundary_surface(&[side(0)?, apex], &[side(1)?, side(2)?])?)
|
||||
}
|
||||
_ => Err(DocError::Invalid("a loop patch takes three or four curves".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A network curve resolved in the feature's frame, owning what it is built from.
|
||||
enum NetSource {
|
||||
Sketch(solidcraft_geom::Plane, Vec<Seg2>),
|
||||
Points(Vec<Vec3>),
|
||||
Edges(kernel::Body, Vec<Vec3>),
|
||||
Point(Vec3),
|
||||
}
|
||||
|
||||
/// The curves of one direction of a network, resolved: each one sketch chain, 3D sketch chain,
|
||||
/// edge chain or point, labelled `U1`, `U2`, … (with `dir` "U") for messages.
|
||||
fn network_sources(
|
||||
doc: &Document,
|
||||
st: &ModelState,
|
||||
f: &Feature,
|
||||
list: &[crate::NetworkCurve],
|
||||
dir: &str,
|
||||
) -> Result<Vec<(String, NetSource, kernel::Continuity)>> {
|
||||
if list.len() > MAX_NETWORK_CURVES {
|
||||
return Err(DocError::Invalid(format!("at most {MAX_NETWORK_CURVES} curves each way")));
|
||||
}
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for (k, c) in list.iter().enumerate() {
|
||||
let label = format!("{dir}{}", k + 1);
|
||||
let given = [c.sketch.is_some() || !c.curves.is_empty(), c.body.is_some() || !c.edges.is_empty(), c.point.is_some()];
|
||||
if given.iter().filter(|g| **g).count() != 1 {
|
||||
return Err(DocError::Invalid(format!("{label} must be sketch curves, a body's edges or a point")));
|
||||
}
|
||||
let source = if let Some(p) = c.point {
|
||||
NetSource::Point(p.0)
|
||||
} else if let Some(name) = &c.body {
|
||||
if c.edges.is_empty() {
|
||||
return Err(DocError::Invalid(format!("{label} needs points on the edges of {name}")));
|
||||
}
|
||||
let b = st.body(name).ok_or_else(|| DocError::Unknown(format!("body `{name}`")))?;
|
||||
NetSource::Edges(b.body.clone(), crate::plain(&c.edges))
|
||||
} else {
|
||||
let sk = c.sketch.ok_or_else(|| DocError::Invalid(format!("{label} needs the sketch its curves are in")))?;
|
||||
if c.curves.is_empty() || c.curves.len() > MAX_CHAIN_CURVES {
|
||||
return Err(DocError::Invalid(format!("{label} needs 1 to {MAX_CHAIN_CURVES} curves of its sketch")));
|
||||
}
|
||||
let raw = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
|
||||
// 3D sketch curves are followed through their points; planar ones exactly.
|
||||
let mapped = path_in_frame(doc, raw, f.component)?;
|
||||
match path_points(&mapped, &c.curves, Vec3::new(f64::NAN, f64::NAN, f64::NAN))? {
|
||||
Some(pts) => NetSource::Points(pts),
|
||||
None => {
|
||||
let ss = sketch_in_feature_frame(doc, raw, f.component)?;
|
||||
let mut chains = curve_chains(&ss, &c.curves)?.into_iter();
|
||||
let (Some(chain), None) = (chains.next(), chains.next()) else {
|
||||
return Err(DocError::Invalid(format!("{label}: its curves must meet end to end in one chain")));
|
||||
};
|
||||
NetSource::Sketch(ss.plane, chain)
|
||||
}
|
||||
}
|
||||
};
|
||||
out.push((label, source, c.continuity));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The kernel's curves over resolved sources (borrowing their bodies).
|
||||
fn network_curves(sources: &[(String, NetSource, kernel::Continuity)]) -> Vec<kernel::BoundaryCurve<'_>> {
|
||||
sources
|
||||
.iter()
|
||||
.map(|(label, src, g)| kernel::BoundaryCurve {
|
||||
label: label.clone(),
|
||||
source: match src {
|
||||
NetSource::Sketch(plane, segs) => kernel::CurveSource::Sketch { plane: *plane, segs: segs.clone() },
|
||||
NetSource::Points(pts) => kernel::CurveSource::Points(pts.clone()),
|
||||
NetSource::Edges(body, at) => kernel::CurveSource::Edges { body, at: at.clone() },
|
||||
NetSource::Point(p) => kernel::CurveSource::Point(*p),
|
||||
},
|
||||
continuity: *g,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The boundaries `surface.boundary` builds its patch from: each picked curve's own stations in
|
||||
/// the sketch's frame, every one of them leaving the boundary square into the loop — the direction
|
||||
/// from the curve towards the middle of the loop, taken across its tangent — over the plane the
|
||||
|
||||
@@ -164,6 +164,10 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
// The Class-A surface family (surface.blend/boundary/match/fair).
|
||||
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
|
||||
("surface_boundary", json!({"type": "surface_boundary", "sketch": 1, "curves": ["l1", "l2"]})),
|
||||
(
|
||||
"surface_network",
|
||||
json!({"type": "surface_network", "u": [{"sketch": 1, "curves": ["l1"]}, {"body": "B", "edges": [o], "continuity": "g1"}], "v": [{"sketch": 2, "curves": ["l1"]}, {"point": o}], "operation": "join"}),
|
||||
),
|
||||
("surface_match", json!({"type": "surface_match", "body": "B", "faces": [o], "target": "C", "edges": [o]})),
|
||||
("surface_fair", json!({"type": "surface_fair", "body": "B", "faces": [o], "tolerance": "sfair.tolerance"})),
|
||||
("unstitch", json!({"type": "unstitch", "body": "B"})),
|
||||
|
||||
@@ -342,6 +342,7 @@ surface_offset
|
||||
expressions: soff.distance, soff.tolerance
|
||||
surface_blend
|
||||
surface_boundary
|
||||
surface_network
|
||||
surface_match
|
||||
surface_fair
|
||||
inputs:
|
||||
|
||||
@@ -157,6 +157,8 @@ surface_offset
|
||||
surface_blend
|
||||
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
|
||||
surface_boundary
|
||||
surface_network
|
||||
moves: u[1].edges[0][0], u[1].edges[0][1], u[1].edges[0][2], v[1].point[0], v[1].point[1], v[1].point[2]
|
||||
surface_match
|
||||
moves: edges[0][0], edges[0][1], edges[0][2], faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
|
||||
@@ -182,6 +182,10 @@ surface_blend
|
||||
surface_boundary
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
surface_network
|
||||
sketches: [1, 2]
|
||||
remap_refs: u[0].sketch, v[0].sketch
|
||||
remap_bodies: u[1].body
|
||||
surface_match
|
||||
remap_bodies: body
|
||||
surface_fair
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! A `SurfaceNetwork` (Boundary Surface through a curve network, #763) saves `u` and `v`, each a
|
||||
//! list of curves: a `sketch` and its `curves`, a `body` and points on its `edges`, or a `point`
|
||||
//! (the apex of a three-sided patch), with an optional `continuity` (`g1` or `g2` with the face
|
||||
//! beside an edge chain), and an `operation` (`new_body`, or `join` into the body of its edges).
|
||||
//! A `SurfaceBoundary` of three or four curves, which older builds refused, now builds and saves
|
||||
//! as it always did. Older designs need no transformation; the last release asks for an update
|
||||
//! instead of meeting a feature it can't read.
|
||||
@@ -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;
|
||||
|
||||
@@ -808,3 +808,43 @@ fn multi_target_join_keeps_nearby_bodies_and_original_absorption_order() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,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")),
|
||||
@@ -1004,6 +1004,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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -547,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();
|
||||
|
||||
@@ -952,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.
|
||||
|
||||
@@ -393,8 +393,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 +474,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 +514,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 {
|
||||
|
||||
@@ -287,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<_>>()}))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,38 +1,56 @@
|
||||
//! Boundary surfaces: one patch through a network of two to four sketch curves (`surface.boundary`,
|
||||
//! issue #763). The kernel builds the face from the curves' own frames; this is the command's
|
||||
//! parameters and its checks, so that a loop it cannot hold is refused before anything is built.
|
||||
//! Boundary surfaces (`surface.boundary`, issue #763): one patch through two to four sketch
|
||||
//! curves in loop order, or one surface through a network of curves in two directions (sketch
|
||||
//! curves, 3D sketch curves, a body's edges, or a point for a three-sided patch). The kernel
|
||||
//! builds the face; this is the command's parameters and its checks, so that what it cannot hold
|
||||
//! is refused before anything is built.
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{FeatureKind, SketchId};
|
||||
use solidcraft_doc::{BodyName, FeatureKind, NetworkCurve, Operation, Position, SketchId};
|
||||
use solidcraft_kernel::Continuity;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, feature_sketch};
|
||||
use crate::params::{bad, string_list};
|
||||
use super::features::{add_feature, feature_sketch, sketch_id};
|
||||
use crate::params::{bad, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The stations a boundary carries by default (the patch's resolution along the loop).
|
||||
const DEFAULT_SAMPLES: usize = 25;
|
||||
const DEFAULT_SAMPLES: usize = solidcraft_doc::BOUNDARY_SAMPLES;
|
||||
/// The fewest stations a patch can hold the widest continuity pair with: a G3 side needs four rows
|
||||
/// at each of two opposite sides, so eight.
|
||||
const MIN_SAMPLES: usize = 8;
|
||||
/// The most stations a boundary may carry (the kernel's own cap).
|
||||
const MAX_SAMPLES: usize = 512;
|
||||
/// The most curves one direction of a network may list (the kernel's own cap).
|
||||
const MAX_NETWORK_CURVES: usize = solidcraft_geom::network::MAX_NETWORK_CURVES;
|
||||
/// The most sketch curves one network curve may chain, and points picking one chain of edges
|
||||
/// (the kernel's own caps).
|
||||
use solidcraft_kernel::{MAX_CHAIN_CURVES as MAX_CHAIN, MAX_EDGE_PICKS as MAX_PICKS};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("surface.boundary", "Boundary Surface", boundary)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_boundary")
|
||||
.params(
|
||||
"sketch?: id|name (default: the sketch being edited); curves: [2 to 4 sketch curve ids in loop order] (each one ends where the next begins and the last where the first does; the patch runs through them and leaves each boundary along the tangent across it); continuity?: g0|g1|g2|g3 | [one per curve] (default g1: how much of the surface across each boundary the patch repeats — a sketch curve's own plane, so g1 and up hold exactly and g0 lets the middle bow); crown?: number -0.9…3 (default 0: how full the middle is, without touching a boundary); samples?: 8…512 stations per boundary (default 25); name?, body_name? (a surface body)",
|
||||
"Either a loop of one sketch's curves — sketch?: id|name (default: the sketch being edited); curves: [2 to 4 sketch curve ids in loop order] (each one ends where the next begins and the last where the first does; two: the patch spans between them and leaves each boundary along the tangent across it; three or four: the flat patch of the loop, three collapsing the side opposite the first curve to the corner there); continuity?: g0|g1|g2|g3 | [one per curve] (default g1: how much of the surface across each boundary the patch repeats — a sketch curve's own plane, so g1 and up hold exactly and g0 lets the middle bow); crown?: number -0.9…3 (default 0: how full the middle is, without touching a boundary; two curves only); samples?: 8…512 stations per boundary (default 25) — or a curve network — u: [curve] (two or more one way: the surface lofts through them), v?: [curve] (crossing every u-curve once: two each way give a Coons patch, more a Gordon surface through every curve; a curve that misses another by more than 0.01 mm is refused, naming the pair), each curve \"id\" (a curve of `sketch`) | {sketch?: id|name, curves: [ids meeting end to end]} (planar or 3D sketch curves) | {body: name, edges: [[x,y,z]] (points on a chain of its edges), continuity?: g0|g1|g2 (default g0; g1 the surface leaves tangent to the face beside a surface's open edges, g2 with its curvature too; on curves of one direction only)} | {point: [x,y,z]} (a side collapsed to a point: a three-sided patch); operation?: new|join (default new; join sews the surface into the body of its first edge curve, a solid when it closes the gap). name?, body_name? (a surface body)",
|
||||
)];
|
||||
|
||||
/// `surface.boundary`: one patch through a network of two to four sketch curves.
|
||||
/// `surface.boundary`: a loop of sketch curves, or a network when `u` is given.
|
||||
fn boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("u").is_some_and(|v| !v.is_null()) {
|
||||
return network(s, p);
|
||||
}
|
||||
loop_patch(s, p)
|
||||
}
|
||||
|
||||
/// `surface.boundary` with `curves`: one patch through two to four sketch curves in loop order.
|
||||
fn loop_patch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.boundary";
|
||||
if p.get("v").is_some_and(|v| !v.is_null()) || p.get("operation").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(cmd, "`v` and `operation` belong to a curve network: give its curves as `u` and `v`"));
|
||||
}
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let curves = string_list(p, "curves");
|
||||
if !(2..=4).contains(&curves.len()) {
|
||||
return Err(bad(cmd, "`curves` must list 2 to 4 sketch curves in loop order"));
|
||||
return Err(bad(cmd, "`curves` must list 2 to 4 sketch curves in loop order (or give a network as `u` and `v`)"));
|
||||
}
|
||||
// The curves must be the sketch's own, and they must run on into one another: the last station
|
||||
// of each is the first of the next, and the last boundary's end is the first one's start.
|
||||
@@ -98,9 +116,134 @@ fn boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(cmd, format!("`samples` must be a whole number of stations between {MIN_SAMPLES} and {MAX_SAMPLES}")))?
|
||||
}
|
||||
};
|
||||
if crown != 0.0 && curves.len() >= 3 {
|
||||
return Err(bad(cmd, "not supported yet: `crown` on a patch through three or four curves (it is the flat patch of its sides)"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::SurfaceBoundary { sketch: SketchId(sketch), curves, orders, crown, samples })
|
||||
}
|
||||
|
||||
/// `surface.boundary` with `u` (and `v`): one surface through a curve network.
|
||||
fn network(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.boundary";
|
||||
for key in ["curves", "continuity", "crown", "samples"] {
|
||||
if p.get(key).is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(cmd, format!("`{key}` belongs to a loop of sketch curves: a network gives each curve its own continuity")));
|
||||
}
|
||||
}
|
||||
let operation = super::features::operation(p, cmd)?;
|
||||
if !matches!(operation, Operation::NewBody | Operation::Join) {
|
||||
return Err(bad(cmd, "`operation` must be new or join"));
|
||||
}
|
||||
// The sketch a bare curve id belongs to, found only when one is used.
|
||||
let mut default_sketch: Option<u64> = None;
|
||||
let u = network_curves(s, p, "u", &mut default_sketch)?;
|
||||
let v = network_curves(s, p, "v", &mut default_sketch)?;
|
||||
if u.is_empty() {
|
||||
return Err(bad(cmd, "`u` must list the curves of one direction"));
|
||||
}
|
||||
if u.len() + v.len() < 2 {
|
||||
return Err(bad(cmd, "a boundary surface needs two curves in one direction, or crossing curves"));
|
||||
}
|
||||
if operation == Operation::Join && !u.iter().chain(&v).any(|c| c.body.is_some()) {
|
||||
return Err(bad(cmd, "`operation` join needs a curve on a body's edges: the surface is sewn into that body"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::SurfaceNetwork { u, v, operation })
|
||||
}
|
||||
|
||||
/// One direction of a network (`key` is `u` or `v`), checked: each curve sketch curves that exist
|
||||
/// (planar or 3D), a body's edges with points on them, or a point; a continuity only on edges.
|
||||
fn network_curves(s: &Session, p: &Value, key: &str, default_sketch: &mut Option<u64>) -> Result<Vec<NetworkCurve>> {
|
||||
let cmd = "surface.boundary";
|
||||
let list = match p.get(key) {
|
||||
None | Some(Value::Null) => return Ok(Vec::new()),
|
||||
Some(Value::Array(a)) => a,
|
||||
Some(_) => return Err(bad(cmd, format!("`{key}` must list curves"))),
|
||||
};
|
||||
if list.len() > MAX_NETWORK_CURVES {
|
||||
return Err(bad(cmd, format!("`{key}` may list at most {MAX_NETWORK_CURVES} curves")));
|
||||
}
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for (k, item) in list.iter().enumerate() {
|
||||
let name = format!("{key}[{k}]");
|
||||
let mut curve = NetworkCurve { sketch: None, curves: Vec::new(), body: None, edges: Vec::new(), point: None, continuity: Continuity::G0 };
|
||||
let obj = match item {
|
||||
Value::String(id) => {
|
||||
curve.curves = vec![id.clone()];
|
||||
None
|
||||
}
|
||||
Value::Object(_) => Some(item),
|
||||
_ => return Err(bad(cmd, format!("{name} must be a curve id or {{sketch?, curves}}, {{body, edges}} or {{point}}"))),
|
||||
};
|
||||
if let Some(o) = obj {
|
||||
let has = |k: &str| o.get(k).is_some_and(|v| !v.is_null());
|
||||
let kinds = [has("curves"), has("body") || has("edges"), has("point")];
|
||||
if kinds.iter().filter(|x| **x).count() != 1 {
|
||||
return Err(bad(cmd, format!("{name} must be one of {{sketch?, curves}}, {{body, edges}} or {{point}}")));
|
||||
}
|
||||
if has("point") {
|
||||
curve.point = Some(Position(o.get("point").and_then(vec3).ok_or_else(|| bad(cmd, format!("{name}.point must be [x, y, z]")))?));
|
||||
} else if kinds[1] {
|
||||
let body = str_(o, "body").ok_or_else(|| bad(cmd, format!("{name}.body must name a body")))?;
|
||||
if s.model.state().body(body).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
let edges =
|
||||
o.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("{name}.edges must list points on the edges")))?;
|
||||
if edges.is_empty() || edges.len() > MAX_PICKS {
|
||||
return Err(bad(cmd, format!("{name}.edges must list 1 to {MAX_PICKS} points on a chain of edges")));
|
||||
}
|
||||
let pts: Vec<Position> = edges
|
||||
.iter()
|
||||
.map(|e| vec3(e).map(Position).ok_or_else(|| bad(cmd, format!("{name}.edges: a point must be [x, y, z]"))))
|
||||
.collect::<Result<_>>()?;
|
||||
curve.body = Some(BodyName::from(body.to_string()));
|
||||
curve.edges = pts;
|
||||
} else {
|
||||
curve.curves = string_list(o, "curves");
|
||||
if o.get("sketch").is_some_and(|v| !v.is_null()) {
|
||||
curve.sketch = Some(SketchId(sketch_id(s, o.get("sketch"), cmd, &format!("{name}.sketch"))?));
|
||||
}
|
||||
}
|
||||
if let Some(g) = o.get("continuity").filter(|v| !v.is_null()) {
|
||||
let g = g.as_str().and_then(Continuity::parse).ok_or_else(|| bad(cmd, format!("{name}.continuity must be g0, g1 or g2")))?;
|
||||
if g == Continuity::G3 {
|
||||
return Err(bad(cmd, format!("not supported yet: G3 on {name} (a curve network holds up to g2)")));
|
||||
}
|
||||
if g != Continuity::G0 && curve.body.is_none() {
|
||||
return Err(bad(cmd, format!("{name}: g1 and g2 apply to a curve on a body's edges (a surface's open edges)")));
|
||||
}
|
||||
curve.continuity = g;
|
||||
}
|
||||
}
|
||||
if curve.point.is_none() && curve.body.is_none() {
|
||||
if curve.curves.is_empty() || curve.curves.len() > MAX_CHAIN {
|
||||
return Err(bad(cmd, format!("{name} must list 1 to {MAX_CHAIN} sketch curves")));
|
||||
}
|
||||
let sketch = match curve.sketch {
|
||||
Some(id) => id.0,
|
||||
None => match *default_sketch {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let id = feature_sketch(s, p, cmd)?;
|
||||
*default_sketch = Some(id);
|
||||
id
|
||||
}
|
||||
},
|
||||
};
|
||||
let state = s.model.state();
|
||||
let sk = state.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch has not been solved"))?;
|
||||
for id in &curve.curves {
|
||||
if sk.sketch.curve_index(id).is_none() && sk.sketch.wire_index(id).is_none() {
|
||||
return Err(bad(cmd, format!("{name}: no sketch curve `{id}`")));
|
||||
}
|
||||
}
|
||||
curve.sketch = Some(SketchId(sketch));
|
||||
}
|
||||
out.push(curve);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Value, json};
|
||||
@@ -154,27 +297,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boundary_patch_refuses_a_loop_and_values_it_cannot_hold() {
|
||||
fn a_boundary_patch_refuses_values_it_cannot_hold() {
|
||||
let mut s = new_sketch();
|
||||
let curves = two_walls(&mut s);
|
||||
let extra = ids(&run(&mut s, "sketch.line", json!({"points": [[30, 30], [40, 40]]}))["created"]["curves"])[0].clone();
|
||||
// A loop of three or four: the kernel refuses it with its own reason (a tensor-product patch
|
||||
// cannot hold two sides' data at a corner, issue #763) rather than building it approximately.
|
||||
let triangle = ["l1", "l2", "l3"].map(|id| {
|
||||
let _ = id;
|
||||
String::new()
|
||||
});
|
||||
let _ = triangle;
|
||||
// A loop of three: the crown is the bridge's, not the flat loop patch's.
|
||||
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["created"]["curves"])[0].clone();
|
||||
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 0], [10, 15]]}))["created"]["curves"])[0].clone();
|
||||
let c = ids(&run(&mut s, "sketch.line", json!({"points": [[10, 15], [0, 0]]}))["created"]["curves"])[0].clone();
|
||||
let e = s.execute("surface.boundary", &json!({"curves": [a, b, c], "continuity": "g2"})).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet") && e.contains("#763"), "three boundaries: {e}");
|
||||
// A closed square of four lines, in loop order.
|
||||
let sq = [[[0, 0], [10, 0]], [[10, 0], [10, 10]], [[10, 10], [0, 10]], [[0, 10], [0, 0]]];
|
||||
let square: Vec<String> = sq.iter().map(|p| ids(&run(&mut s, "sketch.line", json!({"points": p}))["created"]["curves"])[0].clone()).collect();
|
||||
let e = s.execute("surface.boundary", &json!({"curves": square})).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet") && e.contains("#763"), "four boundaries: {e}");
|
||||
let e = s.execute("surface.boundary", &json!({"curves": [a, b, c], "crown": 1})).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet") && e.contains("crown"), "three boundaries with a crown: {e}");
|
||||
// Curves out of loop order.
|
||||
let e = s.execute("surface.boundary", &json!({"curves": [curves[0], curves[1], extra]})).unwrap_err().to_string();
|
||||
assert!(e.contains("loop order"), "{e}");
|
||||
// Parameters and curves that make no sense.
|
||||
let cases: Vec<(Value, &str)> = vec![
|
||||
(json!({"curves": [curves[0]]}), "2 to 4"),
|
||||
@@ -186,6 +321,7 @@ mod tests {
|
||||
(json!({"curves": curves, "crown": "full"}), "crown"),
|
||||
(json!({"curves": curves, "samples": 3}), "samples"),
|
||||
(json!({"curves": curves, "samples": 2.5}), "samples"),
|
||||
(json!({"curves": curves, "operation": "join"}), "network"),
|
||||
];
|
||||
for (params, want) in cases {
|
||||
let e = s.execute("surface.boundary", ¶ms).unwrap_err().to_string();
|
||||
@@ -195,3 +331,7 @@ mod tests {
|
||||
assert!(s.model.state().bodies.is_empty(), "a refused patch left a body behind");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "surface_boundary_tests.rs"]
|
||||
mod network_tests;
|
||||
@@ -0,0 +1,267 @@
|
||||
//! `surface.boundary` through loops of three or four sketch curves and through curve networks
|
||||
//! (#763): analytic areas and shapes, G1 and G2 measured across the edge a surface shares with a
|
||||
//! body (`inspect.continuity`), and refusals of hostile networks that never panic.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn first(v: &Value) -> String {
|
||||
v.as_array().and_then(|a| a.first()).and_then(Value::as_str).map(str::to_string).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A sketch line's id.
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
|
||||
first(&run(s, "sketch.line", json!({"points": [a, b]}))["created"]["curves"])
|
||||
}
|
||||
|
||||
/// A 3D sketch line's id (the active sketch must have 3D Sketch on).
|
||||
fn line3d(s: &mut Session, a: [f64; 3], b: [f64; 3]) -> String {
|
||||
first(&run(s, "sketch.line3d", json!({"points": [a, b]}))["curves"])
|
||||
}
|
||||
|
||||
/// The area of the one face of `body`, and the area `inspect.measure` gives for the whole model.
|
||||
fn areas(s: &mut Session, body: &str) -> (f64, f64) {
|
||||
let f = run(s, "model.faces", json!({"body": body}));
|
||||
let faces = f["faces"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(faces.len(), 1, "one face: {f}");
|
||||
let face = faces[0]["area"].as_f64().unwrap_or(f64::NAN);
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
(face, m["total"]["area_mm2"].as_f64().unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
/// Three and four sketch curves in loop order: the flat patch of the loop. A triangle and a square
|
||||
/// of lines, and a square whose top is a half circle bulging out, each measure their own area.
|
||||
#[test]
|
||||
fn three_and_four_curve_loops_are_flat_patches() {
|
||||
use std::f64::consts::PI;
|
||||
let cases: Vec<(Vec<[[f64; 2]; 2]>, f64, f64)> = vec![
|
||||
(vec![[[0.0, 0.0], [20.0, 0.0]], [[20.0, 0.0], [10.0, 15.0]], [[10.0, 15.0], [0.0, 0.0]]], 150.0, 1e-6),
|
||||
(vec![[[0.0, 0.0], [10.0, 0.0]], [[10.0, 0.0], [10.0, 10.0]], [[10.0, 10.0], [0.0, 10.0]], [[0.0, 10.0], [0.0, 0.0]]], 100.0, 1e-6),
|
||||
];
|
||||
for (lines, want, tol) in cases {
|
||||
for continuity in [json!("g0"), json!("g1"), json!("g3")] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let curves: Vec<String> = lines.iter().map(|[a, b]| line(&mut s, *a, *b)).collect();
|
||||
let r = run(&mut s, "surface.boundary", json!({"curves": curves, "continuity": continuity}));
|
||||
let body = first(&r["created_bodies"]);
|
||||
let (face, total) = areas(&mut s, &body);
|
||||
assert!(
|
||||
(face - want).abs() < tol * want && (total - want).abs() < tol * want,
|
||||
"{} sides, {continuity}: {face} / {total} vs {want}",
|
||||
lines.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
// A square with a half circle for its top, bulging out: 100 + 12.5π.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let bottom = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let right = line(&mut s, [10.0, 0.0], [10.0, 10.0]);
|
||||
let arc = first(&run(&mut s, "sketch.arc.three_point", json!({"start": [10, 10], "end": [0, 10], "through": [5, 15]}))["created"]["curves"]);
|
||||
let left = line(&mut s, [0.0, 10.0], [0.0, 0.0]);
|
||||
let r = run(&mut s, "surface.boundary", json!({"curves": [bottom, right, arc, left]}));
|
||||
let (face, total) = areas(&mut s, &first(&r["created_bodies"]));
|
||||
let want = 100.0 + 12.5 * PI;
|
||||
assert!((face - want).abs() < 2e-3 * want && (total - want).abs() < 2e-3 * want, "{face} / {total} vs {want}");
|
||||
}
|
||||
|
||||
/// Simpson's rule over [0, a] × [0, a] with n (even) intervals each way.
|
||||
fn simpson2(f: impl Fn(f64, f64) -> f64, a: f64, n: usize) -> f64 {
|
||||
let h = a / n as f64;
|
||||
let w = |k: usize| {
|
||||
if k == 0 || k == n {
|
||||
1.0
|
||||
} else if k % 2 == 1 {
|
||||
4.0
|
||||
} else {
|
||||
2.0
|
||||
}
|
||||
};
|
||||
let mut sum = 0.0;
|
||||
for i in 0..=n {
|
||||
for j in 0..=n {
|
||||
sum += w(i) * w(j) * f(i as f64 * h, j as f64 * h);
|
||||
}
|
||||
}
|
||||
sum * h * h / 9.0
|
||||
}
|
||||
|
||||
/// Four straight 3D sketch lines bounding the hyperbolic paraboloid z = x·y/10 over the 10 × 10
|
||||
/// square: the network's Coons patch is that surface (every vertex of its mesh on it), and its
|
||||
/// area is the paraboloid's, ∫∫ √(1 + (x/10)² + (y/10)²).
|
||||
#[test]
|
||||
fn a_network_of_3d_lines_is_a_hyperbolic_paraboloid() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
|
||||
let a = line3d(&mut s, [0.0, 0.0, 0.0], [10.0, 0.0, 0.0]);
|
||||
let c = line3d(&mut s, [0.0, 10.0, 0.0], [10.0, 10.0, 10.0]);
|
||||
let d = line3d(&mut s, [0.0, 0.0, 0.0], [0.0, 10.0, 0.0]);
|
||||
let b = line3d(&mut s, [10.0, 0.0, 0.0], [10.0, 10.0, 10.0]);
|
||||
let r = run(&mut s, "surface.boundary", json!({"u": [a, c], "v": [d, b]}));
|
||||
let body = first(&r["created_bodies"]);
|
||||
let want = simpson2(|x, y| (1.0 + (x / 10.0).powi(2) + (y / 10.0).powi(2)).sqrt(), 10.0, 200);
|
||||
let (face, total) = areas(&mut s, &body);
|
||||
// The face's own area comes from the coarse reference mesh (a chord of a fiftieth of a per
|
||||
// cent of the body); the measuring mesh is refined onto the surface.
|
||||
assert!((face - want).abs() < 1e-2 * want && (total - want).abs() < 1e-3 * want, "{face} / {total} vs {want}");
|
||||
let st = s.world_state();
|
||||
let mesh = st.body(&body).map(|b| b.body.tessellate(0.01)).and_then(Result::ok).unwrap_or_default();
|
||||
assert!(!mesh.positions.is_empty());
|
||||
for p in &mesh.positions {
|
||||
assert!((p.z - p.x * p.y / 10.0).abs() < 1e-6, "{p:?} is off the paraboloid");
|
||||
}
|
||||
}
|
||||
|
||||
/// Curves of two planar sketches, one picked by a bare id in the sketch being edited: the loft
|
||||
/// between a line on the ground and one 10 mm across and 5 mm up is the flat strip between them.
|
||||
#[test]
|
||||
fn a_network_takes_curves_from_several_sketches() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Low"}));
|
||||
let low = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 5, "name": "High"}));
|
||||
let high = line(&mut s, [0.0, 10.0], [10.0, 10.0]);
|
||||
let r = run(&mut s, "surface.boundary", json!({"u": [{"sketch": "Low", "curves": [low]}, high]}));
|
||||
let (face, _) = areas(&mut s, &first(&r["created_bodies"]));
|
||||
let want = 10.0 * 125f64.sqrt();
|
||||
assert!((face - want).abs() < 1e-6 * want, "{face} vs {want}");
|
||||
}
|
||||
|
||||
/// A quarter cylinder sheet (R = 10, axis along x, its straight top edge at y = 10, z = 0 open)
|
||||
/// and a line 3D-sketched above it; returns the sheet's name and the line's id.
|
||||
fn cylinder_and_line(s: &mut Session) -> (String, String) {
|
||||
run(s, "sketch.create", json!({"plane": "YZ", "name": "Arc"}));
|
||||
let h = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
|
||||
let arc = first(&run(s, "sketch.arc.three_point", json!({"start": [0, -10], "end": [10, 0], "through": [h, -h]}))["created"]["curves"]);
|
||||
run(s, "sketch.finish", json!({}));
|
||||
let sheet = first(&run(s, "surface.extrude", json!({"sketch": "Arc", "curves": [arc], "distance": 10}))["created_bodies"]);
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Top"}));
|
||||
run(s, "sketch.options", json!({"sketch_3d": true}));
|
||||
let top = line3d(s, [0.0, 3.0, 12.0], [10.0, 3.0, 12.0]);
|
||||
run(s, "sketch.finish", json!({}));
|
||||
(sheet, top)
|
||||
}
|
||||
|
||||
/// The edge a surface built from the cylinder's top edge shares with it, measured with
|
||||
/// `inspect.continuity`: (normal angle in degrees, curvature jump per mm).
|
||||
fn seam(s: &mut Session, body: &str) -> (f64, f64) {
|
||||
let v = run(s, "inspect.continuity", json!({"body": body, "edges": [[5.0, 10.0, 0.0]]}));
|
||||
let e = v["bodies"][0]["edges"].as_array().and_then(|a| a.first()).cloned().unwrap_or_default();
|
||||
(e["normal_angle_deg"]["value"].as_f64().unwrap_or(f64::NAN), e["curvature_jump_per_mm"]["value"].as_f64().unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
/// G1 and G2 against a body: a network from the cylinder's open top edge to a line above, joined
|
||||
/// into the cylinder. Across the shared edge the normals agree within 0.1° for G1 and G2; the
|
||||
/// curvature jumps by much of the cylinder's 1/R with G1 and by under 1% of it with G2.
|
||||
#[test]
|
||||
fn g1_and_g2_networks_meet_a_body_across_its_edge() {
|
||||
let mut jumps = Vec::new();
|
||||
for g in ["g1", "g2"] {
|
||||
let mut s = Session::default();
|
||||
let (sheet, top) = cylinder_and_line(&mut s);
|
||||
let r = run(
|
||||
&mut s,
|
||||
"surface.boundary",
|
||||
json!({"u": [{"body": sheet, "edges": [[5.0, 10.0, 0.0]], "continuity": g}, {"sketch": "Top", "curves": [top]}], "operation": "join"}),
|
||||
);
|
||||
assert!(r["created_bodies"].as_array().is_none_or(Vec::is_empty), "a join makes no new body: {r}");
|
||||
let f = run(&mut s, "model.faces", json!({"body": sheet}));
|
||||
assert_eq!(f["faces"].as_array().map(Vec::len), Some(2), "the surface is sewn into the sheet: {f}");
|
||||
let (angle, jump) = seam(&mut s, &sheet);
|
||||
assert!(angle < 0.1, "{g}: normals {angle}° apart across the edge");
|
||||
jumps.push(jump);
|
||||
}
|
||||
let r = 10.0;
|
||||
assert!(jumps[0] > 0.2 / r, "G1 leaves the curvature behind: jump {}", jumps[0]);
|
||||
assert!(jumps[1] < 0.01 / r, "G2 carries the cylinder's curvature across: jump {}", jumps[1]);
|
||||
}
|
||||
|
||||
/// Networks that cannot be built are refused with a reason — by the command's own checks or by the
|
||||
/// kernel — and leave nothing behind; none of them panics.
|
||||
#[test]
|
||||
fn hostile_networks_are_refused() {
|
||||
let mut s = Session::default();
|
||||
let (sheet, top) = cylinder_and_line(&mut s);
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 40, 0]}));
|
||||
let boxed = s.world_state().bodies.last().map(|b| b.name.clone()).unwrap_or_default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Flat"}));
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
let far = line(&mut s, [0.0, 20.0], [10.0, 20.0]);
|
||||
let ring = first(&run(&mut s, "sketch.circle.center", json!({"center": [50, 50], "radius": 3}))["created"]["curves"]);
|
||||
let bodies = s.world_state().bodies.len();
|
||||
let edge = |g: &str| json!({"body": sheet, "edges": [[5.0, 10.0, 0.0]], "continuity": g});
|
||||
let many: Vec<Value> = (0..33).map(|_| json!(a)).collect();
|
||||
let cases: Vec<(Value, &str)> = vec![
|
||||
(json!({"u": "l1"}), "list"),
|
||||
(json!({"u": [42, a]}), "curve id"),
|
||||
(json!({"u": [{}, a]}), "one of"),
|
||||
(json!({"u": [{"curves": [], "sketch": "Flat"}, a]}), "sketch curves"),
|
||||
(json!({"u": [{"curves": ["nope"]}, a]}), "no sketch curve"),
|
||||
(json!({"u": [{"curves": [a], "sketch": "Nope"}, b]}), "no sketch"),
|
||||
(json!({"u": [{"body": "Nope", "edges": [[0, 0, 0]]}, a]}), "no body"),
|
||||
(json!({"u": [{"body": sheet, "edges": []}, a]}), "edges"),
|
||||
(json!({"u": [{"body": sheet, "edges": [["x", 0, 0]]}, a]}), "[x, y, z]"),
|
||||
(json!({"u": [{"body": sheet, "edges": [[1e300, 0, 0]]}, a]}), "[x, y, z]"),
|
||||
(json!({"u": [{"point": [0, 0]}, a]}), "point"),
|
||||
(json!({"u": [{"curves": [a], "body": sheet, "edges": [[5, 10, 0]]}, b]}), "one of"),
|
||||
(json!({"u": [{"curves": [a], "continuity": "g1"}, b]}), "body's edges"),
|
||||
(json!({"u": [edge("g3"), {"sketch": "Top", "curves": [top]}]}), "not supported yet"),
|
||||
(json!({"u": [edge("g9"), {"sketch": "Top", "curves": [top]}]}), "continuity"),
|
||||
(json!({"u": many}), "at most"),
|
||||
(json!({"u": [a, b], "curves": [a, b]}), "loop"),
|
||||
(json!({"u": [a, b], "crown": 1}), "loop"),
|
||||
(json!({"u": [a, b], "operation": "cut"}), "operation"),
|
||||
(json!({"u": [a, b], "operation": "join"}), "join"),
|
||||
(json!({"u": [a]}), "two curves"),
|
||||
(json!({"u": [], "v": [a, b]}), "`u`"),
|
||||
// The kernel's refusals: curves that miss, a closed curve, G1 on an edge between two faces.
|
||||
(json!({"u": [a, b], "v": [{"curves": [far]}, {"curves": [a]}]}), "don't meet"),
|
||||
(json!({"u": [{"curves": [ring]}, b]}), "closed"),
|
||||
(json!({"u": [{"body": boxed, "edges": [[45, 40, 0]], "continuity": "g1"}, b]}), "open edge"),
|
||||
(json!({"u": [a, {"point": [5, 2, 0]}, b]}), "point"),
|
||||
];
|
||||
for (params, want) in cases {
|
||||
let e = match s.execute("surface.boundary", ¶ms) {
|
||||
Ok(v) => panic!("{params} was built: {v}"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(!matches!(e, crate::EngineError::Internal(..)), "{params}: {e}");
|
||||
assert!(e.to_string().contains(want), "{params}: {e}");
|
||||
}
|
||||
assert_eq!(s.world_state().bodies.len(), bodies, "a refused network left a body behind");
|
||||
}
|
||||
|
||||
/// An edit (`timeline.redefine`) may turn a loop of sketch curves into the network of the same
|
||||
/// curves and back: the feature keeps its place and its name, and the surface its area.
|
||||
#[test]
|
||||
fn an_edit_turns_a_loop_into_a_network_and_back() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Square"}));
|
||||
let sides: Vec<String> = [[[0.0, 0.0], [10.0, 0.0]], [[10.0, 0.0], [10.0, 10.0]], [[10.0, 10.0], [0.0, 10.0]], [[0.0, 10.0], [0.0, 0.0]]]
|
||||
.iter()
|
||||
.map(|[a, b]| line(&mut s, *a, *b))
|
||||
.collect();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "surface.boundary", json!({"sketch": "Square", "curves": sides, "name": "Patch"}));
|
||||
let network = json!({"sketch": "Square", "u": [sides[0], sides[2]], "v": [sides[1], sides[3]]});
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Patch", "command": "surface.boundary", "params": network}));
|
||||
let kind = |s: &Session| s.doc.find_feature("Patch").map(|f| f.kind.clone());
|
||||
assert!(matches!(kind(&s), Some(solidcraft_doc::FeatureKind::SurfaceNetwork { .. })), "{:?}", kind(&s));
|
||||
let body = s.world_state().bodies.first().map(|b| b.name.clone()).unwrap_or_default();
|
||||
assert!((areas(&mut s, &body).0 - 100.0).abs() < 1e-6);
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Patch", "command": "surface.boundary", "params": {"sketch": "Square", "curves": sides}}));
|
||||
assert!(matches!(kind(&s), Some(solidcraft_doc::FeatureKind::SurfaceBoundary { .. })), "{:?}", kind(&s));
|
||||
assert!((areas(&mut s, &body).0 - 100.0).abs() < 1e-6);
|
||||
}
|
||||
@@ -299,3 +299,74 @@ fn a_shell_fillet_does_not_follow_a_gone_edge_onto_a_smooth_seam() {
|
||||
assert!(message.contains("no longer exists"), "{message}");
|
||||
assert!(!message.contains("smooth edge"), "{message}");
|
||||
}
|
||||
|
||||
/// The faces `model.faces` lists for `body`: name, whether it is exact, and centroid.
|
||||
fn listed(s: &mut Session, body: &str) -> Vec<(String, bool, [f64; 3])> {
|
||||
let v = run(s, "model.faces", json!({"body": body}));
|
||||
let faces = v["faces"].as_array().unwrap_or_else(|| panic!("faces of {body}: {v}"));
|
||||
faces
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let c = |k: usize| f["centroid"][k].as_f64().unwrap_or(f64::NAN);
|
||||
(f["name"].as_str().unwrap_or_default().to_string(), f["exact"].as_bool() == Some(true), [c(0), c(1), c(2)])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The face of `faces` whose centroid is within 0.05 of `at`.
|
||||
fn named_at(faces: &[(String, bool, [f64; 3])], at: [f64; 3]) -> String {
|
||||
let near = |c: &[f64; 3]| (0..3).all(|k| (c[k] - at[k]).abs() < 0.05);
|
||||
let found: Vec<&String> = faces.iter().filter(|(_, _, c)| near(c)).map(|(n, _, _)| n).collect();
|
||||
match found.as_slice() {
|
||||
[n] => (*n).clone(),
|
||||
_ => panic!("faces at {at:?}: {found:?} in {faces:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// #1209: an arm with a boss at each end (each boss the radius of the arm's round end under it)
|
||||
/// and a square cut through one. The second boss and the cut are prism booleans sewn slab by
|
||||
/// slab, where the arm's round end and the boss over it are two pieces of one cylinder: every
|
||||
/// face keeps exact history, so the cut's walls are its own four faces and the boss's outer
|
||||
/// cylinder keeps its name, through the fillets on the bore's corners too.
|
||||
#[test]
|
||||
fn a_cut_through_one_of_two_bosses_names_only_its_own_walls() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let (lo0, lo1) = ([16.54527384497186, -18.318724018148217], [159.66891048662592, -12.425397803491872]);
|
||||
let (hi0, hi1) = ([159.66891048662592, 12.425397803491872], [16.54527384497186, 18.318724018148217]);
|
||||
let (j0, j1) = ([162.79697152270225, -13.15736982664994], [162.79697152270225, 13.15736982664994]);
|
||||
let (k0, k1) = ([11.818299035314524, 19.73139143375053], [11.818299035314524, -19.73139143375053]);
|
||||
run(&mut s, "sketch.line", json!({"points": [lo0, lo1]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [159.91576013178314, -18.420317757309917], "start": j0, "end": lo1}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [170.0, 0], "start": j0, "end": j1}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [159.91576013178314, 18.420317757309917], "start": hi0, "end": j1}));
|
||||
run(&mut s, "sketch.line", json!({"points": [hi0, hi1]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [16.95668992023388, 28.310257274511628], "start": k0, "end": hi1}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": k0, "end": k1}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [16.95668992023388, -28.310257274511628], "start": lo0, "end": k1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 16, "body_name": "Arm"}));
|
||||
run(&mut s, "solid.cylinder", json!({"diameter": 46.0, "height": 22, "base": [0, 0, 0], "operation": "join", "targets": ["Arm"]}));
|
||||
let boss = named_at(&listed(&mut s, "Arm"), [19.98, -4.24, 19.0]);
|
||||
assert!(boss.starts_with("F3:side"), "{boss}");
|
||||
run(&mut s, "solid.cylinder", json!({"diameter": 30.0, "height": 20, "base": [170.0, 0, 0], "operation": "join", "targets": ["Arm"]}));
|
||||
let joined = listed(&mut s, "Arm");
|
||||
assert!(joined.iter().all(|(_, exact, _)| *exact), "{joined:?}");
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [6.35, 6.35]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"through_all": true, "operation": "cut", "targets": ["Arm"]}));
|
||||
let cut = listed(&mut s, "Arm");
|
||||
assert!(cut.iter().all(|(_, exact, _)| *exact), "{cut:?}");
|
||||
let walls: Vec<&String> = cut.iter().map(|(n, _, _)| n).filter(|n| n.starts_with("F6:")).collect();
|
||||
assert_eq!(walls.len(), 4, "{cut:?}");
|
||||
for (at, line) in [([0.0, -6.35, 11.0], "l1"), ([6.35, 0.0, 11.0], "l2"), ([0.0, 6.35, 11.0], "l3"), ([-6.35, 0.0, 11.0], "l4")] {
|
||||
assert_eq!(named_at(&cut, at), format!("F6:side:{line}"));
|
||||
}
|
||||
assert_eq!(named_at(&cut, [19.98, -4.24, 19.0]), boss);
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[6.35, 6.35, 10], [-6.35, 6.35, 10], [-6.35, -6.35, 10], [6.35, -6.35, 10]], "radius": 1.5}));
|
||||
let filleted = listed(&mut s, "Arm");
|
||||
let blends = filleted.iter().filter(|(n, _, _)| n.starts_with("F7:blend:")).count();
|
||||
assert_eq!(blends, 4, "{filleted:?}");
|
||||
assert_eq!(named_at(&filleted, [19.98, -4.24, 19.0]), boss);
|
||||
}
|
||||
@@ -807,6 +807,8 @@ fn hostile_params_never_panic() {
|
||||
"continuity",
|
||||
"crown",
|
||||
"faces",
|
||||
"u",
|
||||
"v",
|
||||
];
|
||||
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
|
||||
let ids: Vec<&str> = command_specs()
|
||||
@@ -3691,3 +3693,54 @@ fn pattern_copies_added_after_a_rename_keep_their_numbers() {
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "2"}}}));
|
||||
assert_eq!(names(&s), ["X", "Second"]);
|
||||
}
|
||||
|
||||
/// A sketch side drawn as two collinear lines extrudes into one flat face, not two with an edge
|
||||
/// between them (#1788); the face is named from the first line and answers to the second.
|
||||
#[test]
|
||||
fn a_side_drawn_in_two_collinear_pieces_extrudes_into_one_face() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let r = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [20, 0], [20, 10], [0, 10], [0, 0]]}));
|
||||
let pieces: Vec<String> = r["created"]["curves"].as_array().into_iter().flatten().take(2).filter_map(|c| c.as_str().map(String::from)).collect();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
let faces = |s: &mut Session| run(s, "model.faces", json!({"body": "Body1"}))["faces"].as_array().cloned().unwrap_or_default();
|
||||
let f = faces(&mut s);
|
||||
assert_eq!(f.len(), 6, "a box has six faces: {f:?}");
|
||||
assert_eq!(run(&mut s, "inspect.measure", json!({}))["bodies"][0]["kernel"]["faces"], 6);
|
||||
let names: Vec<&str> = f.iter().filter_map(|x| x["name"].as_str()).collect();
|
||||
assert!(names.contains(&format!("F2:side:{}", pieces[0]).as_str()), "{names:?}");
|
||||
// The second piece's name answers for the same face, so a reference made when the side was
|
||||
// two faces (an edge of the second piece) still finds its edge.
|
||||
let st = s.model.state();
|
||||
let body = st.body("Body1").unwrap();
|
||||
let edge = format!("F2:side:{}|F2:top", pieces[1]);
|
||||
let (at, _) = solidcraft_doc::naming::find_edge(body, &edge, geom::Vec3::new(15.0, 0.0, 5.0)).unwrap();
|
||||
assert!(at.y.abs() < 1e-9 && (at.z - 5.0).abs() < 1e-9, "{edge} at {at:?}");
|
||||
// A sketch attaches to the joined side as to any side.
|
||||
assert_eq!(run(&mut s, "sketch.create", json!({"plane": {"face": [15, 0, 2.5]}}))["attachment"], "exact_face");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
/// A sketch made on the second piece's face while the side was two faces (as in a design saved
|
||||
/// before #1788) stays attached once the side is straight and extrudes into one face.
|
||||
#[test]
|
||||
fn a_sketch_on_one_piece_stays_attached_when_the_side_joins() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let r = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, -2], [20, 0], [20, 10], [0, 10], [0, 0]]}));
|
||||
let first = r["created"]["curves"][0].as_str().unwrap_or_default().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
let faces = |s: &mut Session| run(s, "model.faces", json!({"body": "Body1"}))["faces"].as_array().map(Vec::len).unwrap_or(0);
|
||||
assert_eq!(faces(&mut s), 7);
|
||||
assert_eq!(run(&mut s, "sketch.create", json!({"plane": {"face": [15, -1, 2.5]}}))["attachment"], "exact_face");
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [5, 2.5], "radius": 1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"}));
|
||||
run(&mut s, "sketch.move_point", json!({"point": format!("{first}.end"), "to": [10, 0]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(faces(&mut s), 6, "the side is straight now: one face");
|
||||
let sketch = run(&mut s, "timeline.inspect", json!({"feature": "Sketch2"}));
|
||||
assert!(sketch["error"].is_null() && sketch["warning"].is_null(), "{sketch}");
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Geometry foundation for SolidCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
|
||||
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
|
||||
//! (B-spline basis and NURBS surfaces in [`bspline`], one-surface fills of closed loops in
|
||||
//! [`fill`], T-splines in [`tspline`]). Units are millimetres.
|
||||
//! (B-spline basis and NURBS surfaces in [`bspline`], spline spaces and the operators that make
|
||||
//! splines compatible in [`spline_space`], one-surface fills of closed loops in [`fill`],
|
||||
//! surfaces through curve networks in [`network`], T-splines in [`tspline`]). Units are millimetres.
|
||||
//!
|
||||
//! This crate has no knowledge of sketches, documents or a modelling backend; every other crate
|
||||
//! builds on it.
|
||||
@@ -19,8 +20,11 @@ pub mod fill;
|
||||
pub mod fill_general;
|
||||
mod mesh;
|
||||
pub mod mesh_distance;
|
||||
pub mod network;
|
||||
mod plane;
|
||||
mod profile;
|
||||
pub mod section;
|
||||
pub mod spline_space;
|
||||
pub mod tess;
|
||||
pub mod tspline;
|
||||
mod vec;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,291 @@
|
||||
//! Backend-neutral section geometry in a model-space plane, in millimetres and radians.
|
||||
//!
|
||||
//! Curve and wire indices identify items in this result only, not persistent model geometry.
|
||||
//! Producers own source identity, topology and accuracy evidence. [`SectionGeometry::validate_structure`]
|
||||
//! checks bounded payloads, references and connected wires; it does not certify simple loops,
|
||||
//! winding, hole nesting, region separation, source coincidence or an approximation bound.
|
||||
//! Material producers must supply CCW outer loops and CW holes in the plane's x/y basis,
|
||||
//! whose normal is x cross y. Conversion preserves those supplied orientations.
|
||||
//!
|
||||
//! Stored coordinates are finite and at most 1e12 mm in absolute value. Radii and conic
|
||||
//! weights lie in 1e-12..=1e12; arc starts in -1e6..=1e6 radians and absolute sweeps in
|
||||
//! 1e-12..=one turn (with 1e-12 radians of allowance). Positive conic weights are the supported
|
||||
//! subset. These arithmetic limits do not promise a small absolute error at extreme magnitudes.
|
||||
//! Closure tolerance is 1e-9..=1 mm, independent of approximation accuracy.
|
||||
//!
|
||||
//! Validate after deserialization and before use or serialization. The DTOs deliberately retain
|
||||
//! [`Plane`]'s existing serde representation and construction behavior. Validation after parsing
|
||||
//! cannot bound parsing allocations: an encoded-input boundary must also limit bytes and nesting.
|
||||
//! The per-wire/per-region conversions revalidate the whole result on each call; they are not an
|
||||
//! optimized batch-conversion API. Existing profile area/containment methods are not general
|
||||
//! exact or certified measures for conics and freeform curves. Profile values contain geometry
|
||||
//! only: callers retain the original result's accuracy declarations and local identities.
|
||||
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Loop2, MAX_BEZIER_DEGREE, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
|
||||
/// Aggregate input records/references, and the selected conversion's output items, at most.
|
||||
/// Input counts curves, wires, regions, contacts, curve uses and hole references together.
|
||||
pub const MAX_ITEMS: usize = 100_000;
|
||||
const MAX_NUMBER: f64 = 1e12;
|
||||
const MIN_POSITIVE: f64 = 1e-12;
|
||||
const MAX_START_ANGLE: f64 = 1e6;
|
||||
const FRAME_TOLERANCE: f64 = 1e-8;
|
||||
|
||||
/// A section's geometric result, without source identity, freshness or persistence semantics.
|
||||
/// Empty, open-wire and contact-only results remain distinct; no material is inferred from them.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionGeometry {
|
||||
pub plane: Plane,
|
||||
pub curves: Vec<SectionCurve>,
|
||||
pub wires: Vec<SectionWire>,
|
||||
pub regions: Vec<SectionRegion>,
|
||||
/// Isolated contacts in plane coordinates, not invented closed regions.
|
||||
pub contacts: Vec<Vec2>,
|
||||
}
|
||||
|
||||
/// One curve and the producer's declared accuracy relative to its source.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionCurve {
|
||||
pub geometry: Seg2,
|
||||
pub accuracy: CurveAccuracy,
|
||||
}
|
||||
|
||||
/// How the producer represents its source; structure validation cannot certify this declaration.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum CurveAccuracy {
|
||||
/// An analytic representation without fitting; not a certificate about the source or topology.
|
||||
Analytic,
|
||||
/// A producer-established bound, not merely a requested fitting tolerance.
|
||||
Bounded { max_deviation_mm: f64 },
|
||||
/// A deviation measured at samples only, with no bound between them. Zero remains sampled.
|
||||
Sampled { max_sample_deviation_mm: f64 },
|
||||
}
|
||||
|
||||
/// An ordered connected chain. `closed` is explicit, never inferred or repaired.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionWire {
|
||||
pub curves: Vec<CurveUse>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
/// An oriented use of a curve by its result-local index.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CurveUse {
|
||||
pub curve: u32,
|
||||
pub forward: bool,
|
||||
}
|
||||
|
||||
/// A producer-declared material region referring to closed wires. Shared wires are permitted.
|
||||
/// The outer wire cannot also be a hole, and a hole cannot occur twice in this one region.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SectionRegion {
|
||||
pub outer: u32,
|
||||
pub holes: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("section geometry: {0}")]
|
||||
pub struct Invalid(pub &'static str);
|
||||
|
||||
type Result<T> = std::result::Result<T, Invalid>;
|
||||
|
||||
fn require(condition: bool, message: &'static str) -> Result<()> {
|
||||
if condition { Ok(()) } else { Err(Invalid(message)) }
|
||||
}
|
||||
|
||||
struct Budget(usize);
|
||||
impl Budget {
|
||||
fn add(&mut self, n: usize) -> Result<()> {
|
||||
self.0 = self.0.checked_add(n).ok_or(Invalid("item budget"))?;
|
||||
require(self.0 <= MAX_ITEMS, "item budget")
|
||||
}
|
||||
}
|
||||
|
||||
fn numbers(values: impl IntoIterator<Item = f64>) -> Result<()> {
|
||||
require(values.into_iter().all(|v| v.is_finite() && v.abs() <= MAX_NUMBER), "non-finite or excessive coordinate")
|
||||
}
|
||||
|
||||
fn point(p: Vec2) -> Result<()> {
|
||||
numbers([p.x, p.y])
|
||||
}
|
||||
|
||||
fn vector(p: Vec3) -> Result<()> {
|
||||
numbers([p.x, p.y, p.z])
|
||||
}
|
||||
|
||||
/// Strictly check an existing plane without re-orthonormalizing it or changing its serde format.
|
||||
/// Unit-length and orthogonality checks use 1e-8; the normal is derived as x cross y.
|
||||
pub fn validate_plane(plane: &Plane) -> Result<()> {
|
||||
vector(plane.origin)?;
|
||||
vector(plane.x)?;
|
||||
vector(plane.y)?;
|
||||
require(
|
||||
(plane.x.len() - 1.0).abs() <= FRAME_TOLERANCE
|
||||
&& (plane.y.len() - 1.0).abs() <= FRAME_TOLERANCE
|
||||
&& plane.x.dot(plane.y).abs() <= FRAME_TOLERANCE,
|
||||
"nonorthonormal plane",
|
||||
)
|
||||
}
|
||||
|
||||
fn controls(points: &[Vec2]) -> Result<()> {
|
||||
for p in points {
|
||||
point(*p)?;
|
||||
}
|
||||
let Some((first, rest)) = points.split_first() else { return Err(Invalid("empty controls")) };
|
||||
require(rest.iter().any(|p| p != first), "collapsed curve")
|
||||
}
|
||||
|
||||
fn curve(curve: &SectionCurve) -> Result<()> {
|
||||
match curve.geometry {
|
||||
Seg2::Line { a, b } => controls(&[a, b])?,
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
point(center)?;
|
||||
require(radius.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&radius), "arc radius")?;
|
||||
require(start.is_finite() && start.abs() <= MAX_START_ANGLE, "arc start angle")?;
|
||||
require(sweep.is_finite() && (MIN_POSITIVE..=TAU + 1e-12).contains(&sweep.abs()), "arc sweep")?;
|
||||
}
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => controls(&[p0, p1, p2, p3])?,
|
||||
Seg2::Conic { a, apex, b, w } => {
|
||||
controls(&[a, apex, b])?;
|
||||
require(w.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&w), "conic weight")?;
|
||||
}
|
||||
Seg2::Bezier { n, ref p } => {
|
||||
require((4..=MAX_BEZIER_DEGREE).contains(&usize::from(n)), "Bezier degree")?;
|
||||
// The unused slots are serialized too: reject hidden non-finite/excessive values.
|
||||
for q in p {
|
||||
point(*q)?;
|
||||
}
|
||||
controls(p.get(..=usize::from(n)).ok_or(Invalid("Bezier degree"))?)?;
|
||||
}
|
||||
}
|
||||
require(curve.geometry.start().is_finite() && curve.geometry.end().is_finite(), "non-finite curve endpoint")?;
|
||||
match curve.accuracy {
|
||||
CurveAccuracy::Analytic => Ok(()),
|
||||
CurveAccuracy::Bounded { max_deviation_mm } => deviation(max_deviation_mm),
|
||||
CurveAccuracy::Sampled { max_sample_deviation_mm } => deviation(max_sample_deviation_mm),
|
||||
}
|
||||
}
|
||||
|
||||
fn deviation(value: f64) -> Result<()> {
|
||||
require(value.is_finite() && (0.0..=MAX_NUMBER).contains(&value), "accuracy deviation")
|
||||
}
|
||||
|
||||
impl SectionGeometry {
|
||||
/// Check finite supported payloads, aggregate counts, references and wire connectivity.
|
||||
/// This does not certify material validity, winding, nesting, source identity or accuracy.
|
||||
/// Count preflight precedes geometry evaluation; encoded input needs separate parsing limits.
|
||||
pub fn validate_structure(&self, closure_tolerance_mm: f64) -> Result<()> {
|
||||
let mut budget = Budget(0);
|
||||
budget.add(self.curves.len())?;
|
||||
budget.add(self.wires.len())?;
|
||||
budget.add(self.regions.len())?;
|
||||
budget.add(self.contacts.len())?;
|
||||
for wire in &self.wires {
|
||||
budget.add(wire.curves.len())?;
|
||||
}
|
||||
for region in &self.regions {
|
||||
budget.add(region.holes.len())?;
|
||||
}
|
||||
require(closure_tolerance_mm.is_finite() && (1e-9..=1.0).contains(&closure_tolerance_mm), "closure tolerance must be between 1e-9 and 1 mm")?;
|
||||
validate_plane(&self.plane)?;
|
||||
for c in &self.curves {
|
||||
curve(c)?;
|
||||
}
|
||||
for p in &self.contacts {
|
||||
point(*p)?;
|
||||
}
|
||||
for wire in &self.wires {
|
||||
require(!wire.curves.is_empty(), "empty wire")?;
|
||||
let mut first = None;
|
||||
let mut last: Option<Vec2> = None;
|
||||
for use_ in &wire.curves {
|
||||
let c = &self.curves.get(use_.curve as usize).ok_or(Invalid("curve reference"))?.geometry;
|
||||
let (a, b) = (c.start(), c.end());
|
||||
let (start, end) = if use_.forward { (a, b) } else { (b, a) };
|
||||
require(last.is_none_or(|p| p.dist(start) <= closure_tolerance_mm), "disconnected wire")?;
|
||||
first.get_or_insert(start);
|
||||
last = Some(end);
|
||||
}
|
||||
if wire.closed {
|
||||
require(first.zip(last).is_some_and(|(a, b)| a.dist(b) <= closure_tolerance_mm), "open closed wire")?;
|
||||
}
|
||||
}
|
||||
// One stamp array for all regions: neither pairwise duplicate checks nor clearing the
|
||||
// entire array per region. Sharing the same wire between different regions is allowed.
|
||||
let mut seen = vec![usize::MAX; self.wires.len()];
|
||||
for (stamp, region) in self.regions.iter().enumerate() {
|
||||
for id in std::iter::once(®ion.outer).chain(®ion.holes) {
|
||||
let wire = self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?;
|
||||
require(wire.closed, "region boundary is not closed")?;
|
||||
let mark = seen.get_mut(*id as usize).ok_or(Invalid("wire reference"))?;
|
||||
require(*mark != stamp, "duplicate region boundary")?;
|
||||
*mark = stamp;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy one closed wire to a profile loop, preserving its oriented curve uses.
|
||||
/// Revalidates the whole result; an open wire is never silently closed. Floating-point
|
||||
/// reversal must also keep the converted loop connected within the same tolerance.
|
||||
pub fn to_loop(&self, wire: u32, closure_tolerance_mm: f64) -> Result<Loop2> {
|
||||
self.validate_structure(closure_tolerance_mm)?;
|
||||
let wire = self.wires.get(wire as usize).ok_or(Invalid("wire reference"))?;
|
||||
require(wire.closed, "wire is not closed")?;
|
||||
let mut budget = Budget(0);
|
||||
budget.add(1)?;
|
||||
budget.add(wire.curves.len())?;
|
||||
self.copy_loop(wire, closure_tolerance_mm)
|
||||
}
|
||||
|
||||
/// Copy one declared region to a profile, preserving winding and hole order.
|
||||
/// Revalidates the whole result and preflights expanded output before allocating it.
|
||||
/// This does not certify the producer's region. The original result is unchanged; the
|
||||
/// returned profile contains no accuracy declarations or result-local identities.
|
||||
pub fn to_region(&self, region: u32, closure_tolerance_mm: f64) -> Result<Region2> {
|
||||
self.validate_structure(closure_tolerance_mm)?;
|
||||
let region = self.regions.get(region as usize).ok_or(Invalid("region reference"))?;
|
||||
let mut budget = Budget(0);
|
||||
budget.add(1)?;
|
||||
budget.add(region.holes.len())?;
|
||||
for id in std::iter::once(®ion.outer).chain(®ion.holes) {
|
||||
let wire = self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?;
|
||||
budget.add(wire.curves.len())?;
|
||||
}
|
||||
let outer = self.copy_loop(self.wires.get(region.outer as usize).ok_or(Invalid("wire reference"))?, closure_tolerance_mm)?;
|
||||
let mut holes = Vec::with_capacity(region.holes.len());
|
||||
for id in ®ion.holes {
|
||||
holes.push(self.copy_loop(self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?, closure_tolerance_mm)?);
|
||||
}
|
||||
Ok(Region2 { outer, holes })
|
||||
}
|
||||
|
||||
// Called only while the publicly validated result remains immutably borrowed and the
|
||||
// selected output's expansion has been counted. Index access stays fallible nonetheless.
|
||||
fn copy_loop(&self, wire: &SectionWire, closure_tolerance_mm: f64) -> Result<Loop2> {
|
||||
let mut segs = Vec::with_capacity(wire.curves.len());
|
||||
for use_ in &wire.curves {
|
||||
let c = self.curves.get(use_.curve as usize).ok_or(Invalid("curve reference"))?.geometry;
|
||||
segs.push(if use_.forward { c } else { c.reversed() });
|
||||
}
|
||||
let loop_ = Loop2 { segs };
|
||||
// Reversing an arc changes its stored start to start + sweep. Subtracting the sweep
|
||||
// at its new end need not recover the original angle exactly at large radii.
|
||||
require(loop_.is_closed(closure_tolerance_mm), "converted loop is not closed within tolerance")?;
|
||||
Ok(loop_)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "section_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,409 @@
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use super::*;
|
||||
use crate::{MAX_BEZIER_CTRL, bezier};
|
||||
|
||||
const TOL: f64 = 1e-6;
|
||||
|
||||
fn empty(plane: Plane) -> SectionGeometry {
|
||||
SectionGeometry { plane, curves: vec![], wires: vec![], regions: vec![], contacts: vec![] }
|
||||
}
|
||||
|
||||
fn analytic(geometry: Seg2) -> SectionCurve {
|
||||
SectionCurve { geometry, accuracy: CurveAccuracy::Analytic }
|
||||
}
|
||||
|
||||
fn used(curve: u32, forward: bool) -> CurveUse {
|
||||
CurveUse { curve, forward }
|
||||
}
|
||||
|
||||
fn plate() -> SectionGeometry {
|
||||
let p = Vec2::new;
|
||||
SectionGeometry {
|
||||
plane: Plane::XY.offset(10.0),
|
||||
curves: vec![
|
||||
// Deliberately stored backwards: the wire's use restores the CCW rectangle.
|
||||
analytic(Seg2::Line { a: p(100.0, 0.0), b: p(0.0, 0.0) }),
|
||||
analytic(Seg2::Line { a: p(100.0, 0.0), b: p(100.0, 60.0) }),
|
||||
analytic(Seg2::Line { a: p(100.0, 60.0), b: p(0.0, 60.0) }),
|
||||
analytic(Seg2::Line { a: p(0.0, 60.0), b: p(0.0, 0.0) }),
|
||||
analytic(Seg2::Arc { center: p(50.0, 30.0), radius: 5.0, start: 0.0, sweep: PI }),
|
||||
analytic(Seg2::Arc { center: p(50.0, 30.0), radius: 5.0, start: PI, sweep: PI }),
|
||||
],
|
||||
wires: vec![
|
||||
SectionWire { curves: vec![used(0, false), used(1, true), used(2, true), used(3, true)], closed: true },
|
||||
SectionWire { curves: vec![used(5, false), used(4, false)], closed: true },
|
||||
],
|
||||
regions: vec![SectionRegion { outer: 0, holes: vec![1] }],
|
||||
contacts: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_frame_preserves_named_translated_tilted_offset_and_flip_conventions() {
|
||||
for plane in [Plane::XY, Plane::XZ, Plane::YZ] {
|
||||
validate_plane(&plane).unwrap();
|
||||
}
|
||||
assert_eq!(Plane::XZ.normal(), Vec3::Y);
|
||||
assert_eq!(Plane::XZ.to_world(Vec2::new(3.0, 4.0)), Vec3::new(3.0, 0.0, -4.0));
|
||||
let plane = Plane::from_normal(Vec3::new(13.0, -7.0, 19.0), Vec3::new(2.0, -3.0, 5.0)).unwrap();
|
||||
validate_plane(&plane).unwrap();
|
||||
let local = Vec2::new(7.0, -11.0);
|
||||
let height = 4.5;
|
||||
let point = plane.to_world(local) + plane.normal() * height;
|
||||
assert!(plane.to_local(point).dist(local) < 1e-12);
|
||||
assert!((plane.height(point) - height).abs() < 1e-12);
|
||||
let moved = plane.offset(8.0);
|
||||
validate_plane(&moved).unwrap();
|
||||
assert!((plane.height(moved.origin) - 8.0).abs() < 1e-12);
|
||||
for about_y in [false, true] {
|
||||
let flipped = moved.flipped(about_y);
|
||||
validate_plane(&flipped).unwrap();
|
||||
assert_eq!(flipped.origin, moved.origin);
|
||||
assert_eq!(flipped.normal(), -moved.normal());
|
||||
assert_eq!(flipped.flipped(about_y), moved);
|
||||
let on_plane = moved.to_world(local);
|
||||
let mirrored = if about_y { Vec2::new(-local.x, local.y) } else { Vec2::new(local.x, -local.y) };
|
||||
assert!(flipped.to_local(on_plane).dist(mirrored) < 1e-12);
|
||||
assert!((flipped.height(point) + moved.height(point)).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_frame_validation_does_not_repair_raw_or_deserialized_axes() {
|
||||
let bad = Plane { x: Vec3::X * 2.0, ..Plane::XY };
|
||||
assert!(Plane::new(bad.origin, bad.x, bad.y).is_some());
|
||||
assert_eq!(validate_plane(&bad), Err(Invalid("nonorthonormal plane")));
|
||||
assert_eq!(bad.x, Vec3::X * 2.0);
|
||||
let restored: Plane = serde_json::from_value(serde_json::json!({
|
||||
"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [1, 1, 0]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(validate_plane(&restored), Err(Invalid("nonorthonormal plane")));
|
||||
for plane in [
|
||||
Plane { origin: Vec3::new(f64::NAN, 0.0, 0.0), ..Plane::XY },
|
||||
Plane { origin: Vec3::new(1e12 + 1.0, 0.0, 0.0), ..Plane::XY },
|
||||
Plane { x: Vec3::new(f64::MAX, 0.0, 0.0), ..Plane::XY },
|
||||
Plane { y: Vec3::new(0.0, f64::INFINITY, 0.0), ..Plane::XY },
|
||||
Plane { x: Vec3::ZERO, ..Plane::XY },
|
||||
Plane { y: Vec3::X, ..Plane::XY },
|
||||
] {
|
||||
assert!(validate_plane(&plane).is_err());
|
||||
}
|
||||
validate_plane(&Plane { origin: Vec3::new(1e12, -1e12, 1e12), ..Plane::XY }).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_arc_region_conversion_preserves_winding_and_analytic_area() {
|
||||
let section = plate();
|
||||
let before = section.clone();
|
||||
section.validate_structure(TOL).unwrap();
|
||||
let outer = section.to_loop(0, TOL).unwrap();
|
||||
let hole = section.to_loop(1, TOL).unwrap();
|
||||
assert!(outer.is_closed(TOL) && hole.is_closed(TOL));
|
||||
assert!((outer.signed_area() - 6000.0).abs() < 1e-10);
|
||||
assert!((hole.signed_area() + 25.0 * PI).abs() < 1e-10);
|
||||
let region = section.to_region(0, TOL).unwrap();
|
||||
assert_eq!(region.outer, outer);
|
||||
assert_eq!(region.holes, vec![hole]);
|
||||
assert!((region.area() - (6000.0 - 25.0 * PI)).abs() < 1e-10);
|
||||
assert_eq!(section, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_contacts_and_open_wires_remain_distinct_without_inferred_material() {
|
||||
let empty = empty(Plane::XY);
|
||||
empty.validate_structure(TOL).unwrap();
|
||||
assert_eq!(empty.to_region(0, TOL), Err(Invalid("region reference")));
|
||||
let mut contact = empty.clone();
|
||||
contact.contacts.push(Vec2::new(2.0, 3.0));
|
||||
contact.validate_structure(TOL).unwrap();
|
||||
assert_ne!(contact, empty);
|
||||
let mut open = empty.clone();
|
||||
open.curves = vec![analytic(Seg2::Line { a: Vec2::ZERO, b: Vec2::X }), analytic(Seg2::Line { a: Vec2::X, b: Vec2::new(1.0, 1.0) })];
|
||||
open.wires = vec![SectionWire { curves: vec![used(0, true), used(1, true)], closed: false }];
|
||||
open.validate_structure(TOL).unwrap();
|
||||
assert_eq!(open.to_loop(0, TOL), Err(Invalid("wire is not closed")));
|
||||
open.regions.push(SectionRegion { outer: 0, holes: vec![] });
|
||||
assert_eq!(open.validate_structure(TOL), Err(Invalid("region boundary is not closed")));
|
||||
// Even a geometrically closed chain remains explicitly open when the producer says so.
|
||||
let mut declared_open = plate();
|
||||
declared_open.regions.clear();
|
||||
declared_open.wires[0].closed = false;
|
||||
declared_open.validate_structure(TOL).unwrap();
|
||||
assert_eq!(declared_open.to_loop(0, TOL), Err(Invalid("wire is not closed")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_references_empty_disconnected_and_unclosed_wires_fail() {
|
||||
let mut section = plate();
|
||||
section.wires[0].curves[0].curve = u32::MAX;
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("curve reference")));
|
||||
let mut section = plate();
|
||||
section.wires[0].curves.clear();
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("empty wire")));
|
||||
let mut section = plate();
|
||||
section.wires[0].curves[1].forward = false;
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("disconnected wire")));
|
||||
let mut section = plate();
|
||||
section.wires[0].curves.pop();
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("open closed wire")));
|
||||
let mut section = plate();
|
||||
section.regions[0].outer = u32::MAX;
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("wire reference")));
|
||||
let mut section = plate();
|
||||
section.regions[0].holes[0] = u32::MAX;
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("wire reference")));
|
||||
assert_eq!(plate().to_loop(u32::MAX, TOL), Err(Invalid("wire reference")));
|
||||
assert_eq!(plate().to_region(u32::MAX, TOL), Err(Invalid("region reference")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_boundaries_are_local_to_a_region_and_large_reference_tables_are_bounded() {
|
||||
let mut section = plate();
|
||||
section.regions[0].holes.push(1);
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("duplicate region boundary")));
|
||||
let mut section = plate();
|
||||
section.regions[0].holes.push(0);
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("duplicate region boundary")));
|
||||
let mut section = plate();
|
||||
section.regions.push(section.regions[0].clone());
|
||||
section.validate_structure(TOL).unwrap();
|
||||
assert_eq!(section.to_region(0, TOL).unwrap(), section.to_region(1, TOL).unwrap());
|
||||
// Deliberately coincident boundaries: this tests a large legal reference graph, not material.
|
||||
let mut many = empty(Plane::XY);
|
||||
many.curves.push(analytic(Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep: TAU }));
|
||||
many.wires = vec![SectionWire { curves: vec![used(0, true)], closed: true }; 201];
|
||||
many.regions = vec![SectionRegion { outer: 0, holes: (1..=200).collect() }; 200];
|
||||
many.validate_structure(TOL).unwrap();
|
||||
let last = many.regions.last_mut().unwrap();
|
||||
*last.holes.last_mut().unwrap() = 1;
|
||||
assert_eq!(many.validate_structure(TOL), Err(Invalid("duplicate region boundary")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_counts_fail_before_geometry_or_reference_inspection() {
|
||||
let mut section = empty(Plane::XY);
|
||||
section.contacts = vec![Vec2::new(f64::NAN, 0.0); MAX_ITEMS + 1];
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
|
||||
let mut section = empty(Plane::XY);
|
||||
section.wires.push(SectionWire { curves: vec![used(u32::MAX, false); MAX_ITEMS], closed: false });
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
|
||||
let mut section = empty(Plane::XY);
|
||||
section.regions.push(SectionRegion { outer: u32::MAX, holes: vec![u32::MAX; MAX_ITEMS] });
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
|
||||
let mut section = empty(Plane::XY);
|
||||
section.contacts = vec![Vec2::ZERO; MAX_ITEMS / 2];
|
||||
section.wires.push(SectionWire { curves: vec![used(u32::MAX, false); MAX_ITEMS / 2], closed: false });
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
|
||||
// Exactly the item limit is accepted, independently of these points' material meaning.
|
||||
let mut boundary = empty(Plane::XY);
|
||||
boundary.contacts = vec![Vec2::ZERO; MAX_ITEMS];
|
||||
boundary.validate_structure(TOL).unwrap();
|
||||
let mut count = Budget(MAX_ITEMS);
|
||||
assert_eq!(count.add(usize::MAX), Err(Invalid("item budget")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closure_tolerance_is_bounded_and_not_used_to_collapse_short_curves() {
|
||||
for tolerance in [f64::NAN, f64::INFINITY, -1.0, 0.0, 1e-10, 1.01] {
|
||||
assert!(plate().validate_structure(tolerance).is_err());
|
||||
}
|
||||
for tolerance in [1e-9, 1.0] {
|
||||
plate().validate_structure(tolerance).unwrap();
|
||||
}
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves.push(analytic(Seg2::Line { a: Vec2::ZERO, b: Vec2::new(1e-10, 0.0) }));
|
||||
section.validate_structure(1.0).unwrap();
|
||||
let mut gap = plate();
|
||||
if let Seg2::Line { b, .. } = &mut gap.curves[3].geometry {
|
||||
b.x = 5e-7;
|
||||
}
|
||||
gap.validate_structure(1e-6).unwrap();
|
||||
assert_eq!(gap.validate_structure(1e-8), Err(Invalid("open closed wire")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_number_and_domain_errors_are_rejected_before_evaluation() {
|
||||
let p = Vec2::new;
|
||||
let arc = |radius, start, sweep| Seg2::Arc { center: Vec2::ZERO, radius, start, sweep };
|
||||
let conic = |w| Seg2::Conic { a: Vec2::X, apex: p(1.0, 1.0), b: Vec2::Y, w };
|
||||
let invalid = [
|
||||
Seg2::Line { a: Vec2::ZERO, b: Vec2::ZERO },
|
||||
Seg2::Line { a: Vec2::ZERO, b: p(f64::NAN, 0.0) },
|
||||
Seg2::Line { a: Vec2::ZERO, b: p(f64::MAX, 0.0) },
|
||||
arc(-1.0, 0.0, PI),
|
||||
arc(0.0, 0.0, PI),
|
||||
arc(1e-13, 0.0, PI),
|
||||
arc(1e12 + 1.0, 0.0, PI),
|
||||
arc(f64::INFINITY, 0.0, PI),
|
||||
arc(1.0, f64::NAN, PI),
|
||||
arc(1.0, 1e6 + 1.0, PI),
|
||||
arc(1.0, 0.0, 0.0),
|
||||
arc(1.0, 0.0, 1e-13),
|
||||
arc(1.0, 0.0, TAU + 1e-8),
|
||||
arc(1.0, 0.0, f64::INFINITY),
|
||||
conic(-0.5),
|
||||
conic(0.0),
|
||||
conic(1e-13),
|
||||
conic(1e12 + 1.0),
|
||||
conic(f64::NAN),
|
||||
Seg2::Cubic { p0: Vec2::X, p1: Vec2::X, p2: Vec2::X, p3: Vec2::X },
|
||||
Seg2::Conic { a: Vec2::X, apex: Vec2::X, b: Vec2::X, w: 1.0 },
|
||||
];
|
||||
for geometry in invalid {
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves.push(analytic(geometry));
|
||||
assert!(section.validate_structure(TOL).is_err(), "accepted {geometry:?}");
|
||||
}
|
||||
let mut section = empty(Plane::XY);
|
||||
section.contacts.push(p(f64::INFINITY, 0.0));
|
||||
assert!(section.validate_structure(TOL).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bezier_degree_and_every_serialized_control_slot_are_validated() {
|
||||
for degree in [4u8, 9] {
|
||||
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
|
||||
for (i, point) in p.iter_mut().enumerate().take(usize::from(degree) + 1) {
|
||||
*point = Vec2::new(i as f64, (i % 2) as f64);
|
||||
}
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves.push(analytic(Seg2::Bezier { n: degree, p }));
|
||||
section.validate_structure(TOL).unwrap();
|
||||
for wrong in [0, 3, 10, u8::MAX] {
|
||||
section.curves[0].geometry = Seg2::Bezier { n: wrong, p };
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("Bezier degree")));
|
||||
}
|
||||
}
|
||||
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
|
||||
p[4] = Vec2::X;
|
||||
for value in [f64::NAN, f64::INFINITY, 1e12 + 1.0] {
|
||||
p[MAX_BEZIER_CTRL - 1] = Vec2::new(value, 0.0);
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves.push(analytic(Seg2::Bezier { n: 4, p }));
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("non-finite or excessive coordinate")));
|
||||
}
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves.push(analytic(Seg2::Bezier { n: 4, p: [Vec2::ZERO; MAX_BEZIER_CTRL] }));
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("collapsed curve")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_curve_limits_keep_evaluation_finite_without_claiming_accuracy() {
|
||||
let p = Vec2::new;
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves = vec![
|
||||
analytic(Seg2::Line { a: p(-1e12, 1e12), b: p(1e12, -1e12) }),
|
||||
analytic(Seg2::Arc { center: p(1e12, 1e12), radius: 1e12, start: 1e6, sweep: -TAU }),
|
||||
analytic(Seg2::Arc { center: Vec2::ZERO, radius: 1e-12, start: 0.0, sweep: 1e-12 }),
|
||||
analytic(Seg2::Conic { a: p(-1e12, 0.0), apex: p(0.0, 1e12), b: p(1e12, 0.0), w: 1e12 }),
|
||||
analytic(Seg2::Conic { a: p(-1e12, 0.0), apex: p(0.0, 1e12), b: p(1e12, 0.0), w: 1e-12 }),
|
||||
analytic(bezier(&[p(0.0, 0.0), p(1e12, 0.0), p(0.0, 1e12), p(-1e12, 0.0)]).unwrap()),
|
||||
];
|
||||
let controls: Vec<Vec2> = (0..=9).map(|i| p(i as f64 * 1e11, if i % 2 == 0 { 1e12 } else { -1e12 })).collect();
|
||||
section.curves.push(analytic(bezier(&controls).unwrap()));
|
||||
section.validate_structure(TOL).unwrap();
|
||||
for curve in §ion.curves {
|
||||
for t in [0.0, 0.5, 1.0] {
|
||||
assert!(curve.geometry.point_at(t).is_finite());
|
||||
assert!(curve.geometry.derivative(t).is_finite());
|
||||
assert!(curve.geometry.derivatives(t, 4).is_some_and(|d| d.iter().all(|v| v.is_finite())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_serialization_preserves_accuracy_tags_and_plane_names() {
|
||||
let mut section = plate();
|
||||
section.curves[1].accuracy = CurveAccuracy::Bounded { max_deviation_mm: 0.01 };
|
||||
section.curves[2].accuracy = CurveAccuracy::Sampled { max_sample_deviation_mm: 0.0 };
|
||||
section.validate_structure(TOL).unwrap();
|
||||
let value = serde_json::to_value(§ion).unwrap();
|
||||
assert!(value["plane"].get("x_dir").is_some());
|
||||
assert!(value["plane"].get("y_dir").is_some());
|
||||
assert_eq!(value["curves"][0]["accuracy"], serde_json::json!({"kind": "analytic"}));
|
||||
assert_eq!(value["curves"][1]["accuracy"], serde_json::json!({"kind": "bounded", "max_deviation_mm": 0.01}));
|
||||
assert_eq!(value["curves"][2]["accuracy"], serde_json::json!({"kind": "sampled", "max_sample_deviation_mm": 0.0}));
|
||||
let restored: SectionGeometry = serde_json::from_value(value.clone()).unwrap();
|
||||
restored.validate_structure(TOL).unwrap();
|
||||
assert_eq!(section, restored);
|
||||
let mut unknown = value;
|
||||
unknown["certificate"] = serde_json::json!(true);
|
||||
assert!(serde_json::from_value::<SectionGeometry>(unknown).is_err());
|
||||
assert!(serde_json::from_value::<CurveAccuracy>(serde_json::json!({"kind": "bounded", "max_deviation_mm": 0.1, "exact": true})).is_err());
|
||||
assert!(serde_json::from_value::<CurveAccuracy>(serde_json::json!({"kind": "exact"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accuracy_values_are_validated_without_promoting_zero_sampled_error() {
|
||||
for value in [f64::NAN, f64::INFINITY, -0.01, 1e12 + 1.0] {
|
||||
for accuracy in [CurveAccuracy::Bounded { max_deviation_mm: value }, CurveAccuracy::Sampled { max_sample_deviation_mm: value }] {
|
||||
let mut section = plate();
|
||||
section.curves[0].accuracy = accuracy;
|
||||
assert_eq!(section.validate_structure(TOL), Err(Invalid("accuracy deviation")));
|
||||
}
|
||||
}
|
||||
for value in [0.0, 1e12] {
|
||||
let mut section = plate();
|
||||
section.curves[0].accuracy = CurveAccuracy::Sampled { max_sample_deviation_mm: value };
|
||||
section.validate_structure(TOL).unwrap();
|
||||
section.to_region(0, TOL).unwrap();
|
||||
assert_eq!(section.curves[0].accuracy, CurveAccuracy::Sampled { max_sample_deviation_mm: value });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_revalidates_mutated_data_and_does_not_trust_an_earlier_success() {
|
||||
let mut section = plate();
|
||||
section.validate_structure(TOL).unwrap();
|
||||
section.curves[0].geometry = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(f64::NAN, 0.0) };
|
||||
assert_eq!(section.to_loop(0, TOL), Err(Invalid("non-finite or excessive coordinate")));
|
||||
assert_eq!(section.to_region(0, TOL), Err(Invalid("non-finite or excessive coordinate")));
|
||||
let mut section = plate();
|
||||
section.to_region(0, TOL).unwrap();
|
||||
section.contacts = vec![Vec2::ZERO; MAX_ITEMS];
|
||||
assert_eq!(section.to_region(0, TOL), Err(Invalid("item budget")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_large_arc_conversion_rechecks_actual_connectivity_without_snapping() {
|
||||
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 1e12, start: 0.1, sweep: 1.0 };
|
||||
let mut section = empty(Plane::XY);
|
||||
section.curves = vec![analytic(arc), analytic(Seg2::Line { a: arc.start(), b: arc.end() })];
|
||||
section.wires.push(SectionWire { curves: vec![used(0, false), used(1, true)], closed: true });
|
||||
section.regions.push(SectionRegion { outer: 0, holes: vec![] });
|
||||
let before = section.clone();
|
||||
// Source endpoints coincide exactly, but (start + sweep) - sweep changes the reversed
|
||||
// arc's endpoint by more than this tolerance at this radius.
|
||||
section.validate_structure(1e-9).unwrap();
|
||||
assert!(arc.reversed().end().dist(arc.start()) > 1e-9);
|
||||
assert_eq!(section.to_loop(0, 1e-9), Err(Invalid("converted loop is not closed within tolerance")));
|
||||
assert_eq!(section.to_region(0, 1e-9), Err(Invalid("converted loop is not closed within tolerance")));
|
||||
assert_eq!(section, before);
|
||||
// A caller's explicit larger tolerance can accept the same result without moving endpoints.
|
||||
let converted = section.to_loop(0, 0.01).unwrap();
|
||||
assert_eq!(converted.segs[0], arc.reversed());
|
||||
assert!(converted.is_closed(0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connected_bow_tie_and_wrong_winding_are_not_certified_or_silently_repaired() {
|
||||
let p = Vec2::new;
|
||||
let loop_ = Loop2::polygon(&[p(0.0, 0.0), p(2.0, 2.0), p(0.0, 2.0), p(2.0, 0.0)]);
|
||||
let mut bow = empty(Plane::XY);
|
||||
bow.curves = loop_.segs.iter().copied().map(analytic).collect();
|
||||
bow.wires.push(SectionWire { curves: (0..4).map(|i| used(i, true)).collect(), closed: true });
|
||||
bow.regions.push(SectionRegion { outer: 0, holes: vec![] });
|
||||
bow.validate_structure(TOL).unwrap();
|
||||
assert_eq!(bow.to_loop(0, TOL).unwrap(), loop_);
|
||||
// A successful structural check does not establish a material region, including its winding.
|
||||
let mut reversed = plate();
|
||||
reversed.wires[0].curves.reverse();
|
||||
for use_ in &mut reversed.wires[0].curves {
|
||||
use_.forward = !use_.forward;
|
||||
}
|
||||
reversed.validate_structure(TOL).unwrap();
|
||||
let converted = reversed.to_region(0, TOL).unwrap();
|
||||
assert!((converted.outer.signed_area() + 6000.0).abs() < 1e-10);
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
//! Spline function spaces and the linear operators surface construction is built from: a clamped
|
||||
//! B-spline space ([`Space`]), interpolation in it with derivative (Hermite) conditions
|
||||
//! ([`Interpolant`]), carrying a spline into a larger space that contains it ([`embed`]: Bézier
|
||||
//! degree elevation and Boehm knot insertion, exact), and a monotone piecewise cubic through
|
||||
//! increasing data ([`Monotone`], for reparameterising curves).
|
||||
//!
|
||||
//! These are the pieces that make several splines *compatible* (one degree, one knot vector) so
|
||||
//! that their control points can be combined linearly: lofting, Coons and Gordon surfaces, and
|
||||
//! cross-boundary derivative conditions all reduce to them.
|
||||
//!
|
||||
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.) — §2.2–2.3 (basis functions and
|
||||
//! their derivatives), §5.2 (knot insertion), §5.5 (degree elevation; the Bézier case), §9.2.1
|
||||
//! (global interpolation, knots by averaging the parameters) and §9.2.2 (interpolation with
|
||||
//! end derivatives); Fritsch & Carlson 1980, "Monotone piecewise cubic interpolation" (SIAM J.
|
||||
//! Numer. Anal. 17), with the weighted harmonic-mean slopes of Fritsch & Butland 1984.
|
||||
//!
|
||||
//! Input is hostile: constructors check sizes and finiteness and return [`SplineError`]; no
|
||||
//! function here panics.
|
||||
|
||||
use crate::Vec3;
|
||||
use crate::bspline::{HPoint, MAX_SPLINE_DEGREE, Side, SplineError, SplineResult, check_knots, find_span, insert_knot, invalid, local_basis};
|
||||
|
||||
/// Most basis functions a space used for interpolation may have (its systems are solved densely).
|
||||
pub const MAX_SPACE_DIMENSION: usize = 2048;
|
||||
|
||||
/// Values a spline can carry: numbers and points (vectors).
|
||||
pub trait Linear: Copy {
|
||||
fn zero() -> Self;
|
||||
fn plus(self, o: Self) -> Self;
|
||||
fn times(self, s: f64) -> Self;
|
||||
/// The value as a homogeneous point of weight one (knot insertion works on those).
|
||||
fn to_h(self) -> HPoint;
|
||||
fn from_h(h: HPoint) -> Self;
|
||||
fn is_finite(self) -> bool;
|
||||
}
|
||||
|
||||
impl Linear for f64 {
|
||||
fn zero() -> Self {
|
||||
0.0
|
||||
}
|
||||
fn plus(self, o: Self) -> Self {
|
||||
self + o
|
||||
}
|
||||
fn times(self, s: f64) -> Self {
|
||||
self * s
|
||||
}
|
||||
fn to_h(self) -> HPoint {
|
||||
HPoint { x: self, y: 0.0, z: 0.0, w: 1.0 }
|
||||
}
|
||||
fn from_h(h: HPoint) -> Self {
|
||||
h.x
|
||||
}
|
||||
fn is_finite(self) -> bool {
|
||||
f64::is_finite(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Linear for Vec3 {
|
||||
fn zero() -> Self {
|
||||
Vec3::ZERO
|
||||
}
|
||||
fn plus(self, o: Self) -> Self {
|
||||
self + o
|
||||
}
|
||||
fn times(self, s: f64) -> Self {
|
||||
self * s
|
||||
}
|
||||
fn to_h(self) -> HPoint {
|
||||
HPoint::weighted(self, 1.0)
|
||||
}
|
||||
fn from_h(h: HPoint) -> Self {
|
||||
h.xyz()
|
||||
}
|
||||
fn is_finite(self) -> bool {
|
||||
Vec3::is_finite(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A clamped B-spline space: a degree and a knot vector whose first and last `degree + 1` knots
|
||||
/// are the domain's ends, interior knots repeated at most `degree` times (the space's splines
|
||||
/// are continuous).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Space {
|
||||
degree: usize,
|
||||
knots: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Space {
|
||||
pub fn new(degree: usize, knots: Vec<f64>) -> SplineResult<Space> {
|
||||
if degree == 0 || degree > MAX_SPLINE_DEGREE {
|
||||
return Err(SplineError::Unsupported(format!("degree {degree} (1 to {MAX_SPLINE_DEGREE})")));
|
||||
}
|
||||
check_knots(&knots)?;
|
||||
let n = knots.len().checked_sub(degree + 1).filter(|n| *n > degree).ok_or_else(|| SplineError::Invalid("too few knots".into()))?;
|
||||
if n > MAX_SPACE_DIMENSION {
|
||||
return Err(SplineError::TooLarge(format!("{n} basis functions (at most {MAX_SPACE_DIMENSION})")));
|
||||
}
|
||||
let (a, b) = (knots[0], knots[knots.len() - 1]);
|
||||
if !(a < b) || knots[..=degree].iter().any(|k| *k != a) || knots[n..].iter().any(|k| *k != b) {
|
||||
return invalid("the knot vector must be clamped on a non-empty domain");
|
||||
}
|
||||
let inner = &knots[degree + 1..n];
|
||||
let mut run = 0;
|
||||
for w in inner.windows(2) {
|
||||
run = if w[0] == w[1] { run + 1 } else { 0 };
|
||||
if run + 1 > degree {
|
||||
return invalid("an interior knot is repeated more often than the degree");
|
||||
}
|
||||
}
|
||||
if inner.first().is_some_and(|k| *k == a) || inner.last().is_some_and(|k| *k == b) {
|
||||
return invalid("an interior knot lies on a domain end");
|
||||
}
|
||||
Ok(Space { degree, knots })
|
||||
}
|
||||
|
||||
/// Polynomials of `degree` on `[a, b]` (one Bézier piece).
|
||||
pub fn polynomial(degree: usize, a: f64, b: f64) -> SplineResult<Space> {
|
||||
let mut knots = vec![a; degree + 1];
|
||||
knots.extend(std::iter::repeat_n(b, degree + 1));
|
||||
Space::new(degree, knots)
|
||||
}
|
||||
|
||||
/// Splines of `degree` with a simple knot at every interior break: `breaks` increase
|
||||
/// strictly, the first and last are the domain's ends.
|
||||
pub fn with_breaks(degree: usize, breaks: &[f64]) -> SplineResult<Space> {
|
||||
let (Some(&a), Some(&b)) = (breaks.first(), breaks.last()) else { return invalid("no breaks") };
|
||||
if breaks.len() > MAX_SPACE_DIMENSION || breaks.windows(2).any(|w| !(w[0] < w[1])) {
|
||||
return invalid("the breaks must increase");
|
||||
}
|
||||
let mut knots = vec![a; degree + 1];
|
||||
knots.extend(breaks.iter().skip(1).take(breaks.len().saturating_sub(2)).copied());
|
||||
knots.extend(std::iter::repeat_n(b, degree + 1));
|
||||
Space::new(degree, knots)
|
||||
}
|
||||
|
||||
pub fn degree(&self) -> usize {
|
||||
self.degree
|
||||
}
|
||||
|
||||
pub fn knots(&self) -> &[f64] {
|
||||
&self.knots
|
||||
}
|
||||
|
||||
/// The number of basis functions (control points of a spline in the space).
|
||||
pub fn count(&self) -> usize {
|
||||
self.knots.len() - self.degree - 1
|
||||
}
|
||||
|
||||
/// The domain `[a, b]`.
|
||||
pub fn domain(&self) -> [f64; 2] {
|
||||
[self.knots[0], self.knots[self.knots.len() - 1]]
|
||||
}
|
||||
|
||||
/// The `der`-th derivatives at `t` (clamped to the domain) of the basis functions that do not
|
||||
/// vanish there: the index of the first one and their values.
|
||||
pub fn basis(&self, t: f64, der: usize) -> SplineResult<(usize, Vec<f64>)> {
|
||||
let [a, b] = self.domain();
|
||||
if !t.is_finite() {
|
||||
return invalid("a parameter is not a finite number");
|
||||
}
|
||||
let t = t.clamp(a, b);
|
||||
let (p, n) = (self.degree, self.count());
|
||||
let span = find_span(&self.knots, p, n, t).ok_or_else(|| SplineError::Invalid(format!("{t} is outside the domain")))?;
|
||||
let side = if t >= b { Side::Left } else { Side::Right };
|
||||
let first = span - p;
|
||||
let values =
|
||||
(first..=span).map(|i| self.knots.get(i..i + p + 2).map_or(0.0, |k| local_basis(k, t, side, der).get(der).copied().unwrap_or(0.0)));
|
||||
Ok((first, values.collect()))
|
||||
}
|
||||
|
||||
/// The `der`-th derivative at `t` of the spline with control values `ctrl`.
|
||||
pub fn eval<T: Linear>(&self, ctrl: &[T], t: f64, der: usize) -> SplineResult<T> {
|
||||
if ctrl.len() != self.count() {
|
||||
return invalid(format!("{} control values for {} basis functions", ctrl.len(), self.count()));
|
||||
}
|
||||
let (first, values) = self.basis(t, der)?;
|
||||
Ok(values.iter().zip(ctrl.iter().skip(first)).fold(T::zero(), |acc, (n, c)| acc.plus(c.times(*n))))
|
||||
}
|
||||
|
||||
/// The splines of this space with `breaks` added as simple knots (those already knots are
|
||||
/// left as they are). The result contains this space.
|
||||
pub fn refined(&self, breaks: &[f64]) -> SplineResult<Space> {
|
||||
let [a, b] = self.domain();
|
||||
let mut knots = self.knots.clone();
|
||||
for &x in breaks {
|
||||
if x > a && x < b && !knots.contains(&x) {
|
||||
knots.push(x);
|
||||
}
|
||||
}
|
||||
knots.sort_by(f64::total_cmp);
|
||||
Space::new(self.degree, knots)
|
||||
}
|
||||
}
|
||||
|
||||
/// A condition on a spline: the value (`der` = 0) or a derivative at `t`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Cond {
|
||||
pub t: f64,
|
||||
pub der: usize,
|
||||
}
|
||||
|
||||
/// Interpolation in a space: the spline whose values and derivatives at the conditions are given
|
||||
/// (as many conditions as the space has basis functions). The system is factored once; each
|
||||
/// [`Interpolant::fit`] solves it for new data, so one interpolant serves many curves and the
|
||||
/// rows of a surface alike (the operator is linear).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Interpolant {
|
||||
space: Space,
|
||||
conds: Vec<Cond>,
|
||||
lu: Lu,
|
||||
}
|
||||
|
||||
impl Interpolant {
|
||||
pub fn new(space: Space, conds: Vec<Cond>) -> SplineResult<Interpolant> {
|
||||
let n = space.count();
|
||||
if conds.len() != n {
|
||||
return invalid(format!("{} conditions for {n} basis functions", conds.len()));
|
||||
}
|
||||
let mut a = vec![0.0; n * n];
|
||||
for (r, c) in conds.iter().enumerate() {
|
||||
let (first, values) = space.basis(c.t, c.der)?;
|
||||
for (k, v) in values.iter().enumerate() {
|
||||
if let Some(x) = a.get_mut(r * n + first + k) {
|
||||
*x = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
let lu = Lu::new(n, a).map_err(|_| SplineError::Invalid("the interpolation conditions do not determine a spline".into()))?;
|
||||
Ok(Interpolant { space, conds, lu })
|
||||
}
|
||||
|
||||
/// Interpolation by a single polynomial piece of the lowest degree that meets `conds` (degree
|
||||
/// one less than their number, at least one: a lone value is held constant), on `[a, b]`;
|
||||
/// with more than four conditions, a cubic spline whose interior knots average the
|
||||
/// conditions' parameters (taken as a sequence with one entry per condition, NURBS Book eq.
|
||||
/// 9.8 applied to that confluent sequence, which keeps every basis function's support over
|
||||
/// the condition it is matched with).
|
||||
pub fn lowest(conds: Vec<Cond>, a: f64, b: f64) -> SplineResult<Interpolant> {
|
||||
let mut conds = conds;
|
||||
if conds.iter().any(|c| !c.t.is_finite() || c.t < a || c.t > b) {
|
||||
return invalid("an interpolation parameter is outside the domain");
|
||||
}
|
||||
if conds.len() == 1
|
||||
&& let Some(c) = conds.first().copied()
|
||||
{
|
||||
// A constant: the value and a zero slope (the caller supplies zero data for it).
|
||||
conds.push(Cond { t: c.t, der: 1 });
|
||||
}
|
||||
let k = conds.len();
|
||||
if k <= 4 {
|
||||
return Interpolant::new(Space::polynomial(k.saturating_sub(1).max(1), a, b)?, conds);
|
||||
}
|
||||
let mut tau: Vec<f64> = conds.iter().map(|c| c.t).collect();
|
||||
tau.sort_by(f64::total_cmp);
|
||||
let mut breaks = vec![a];
|
||||
for j in 1..k - 3 {
|
||||
breaks.push((tau[j] + tau[j + 1] + tau[j + 2]) / 3.0);
|
||||
}
|
||||
breaks.push(b);
|
||||
if breaks.windows(2).any(|w| !(w[0] < w[1])) {
|
||||
return invalid("the interpolation parameters repeat too often");
|
||||
}
|
||||
Interpolant::new(Space::with_breaks(3, &breaks)?, conds)
|
||||
}
|
||||
|
||||
pub fn space(&self) -> &Space {
|
||||
&self.space
|
||||
}
|
||||
|
||||
pub fn conds(&self) -> &[Cond] {
|
||||
&self.conds
|
||||
}
|
||||
|
||||
/// The control values of the spline meeting the conditions with `data` (one per condition).
|
||||
pub fn fit<T: Linear>(&self, data: &[T]) -> SplineResult<Vec<T>> {
|
||||
if data.len() != self.conds.len() {
|
||||
return invalid(format!("{} values for {} conditions", data.len(), self.conds.len()));
|
||||
}
|
||||
let out = self.lu.solve(data);
|
||||
if out.iter().any(|x| !x.is_finite()) {
|
||||
return invalid("interpolation did not give finite control values");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// An LU factorisation with partial pivoting of a dense square matrix (row-major).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Lu {
|
||||
n: usize,
|
||||
a: Vec<f64>,
|
||||
piv: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Lu {
|
||||
fn new(n: usize, mut a: Vec<f64>) -> SplineResult<Lu> {
|
||||
if a.len() != n * n {
|
||||
return invalid("matrix size");
|
||||
}
|
||||
let scale = a.iter().fold(0.0f64, |m, x| m.max(x.abs()));
|
||||
if !(scale > 0.0 && scale.is_finite()) {
|
||||
return invalid("singular system");
|
||||
}
|
||||
let mut piv = vec![0; n];
|
||||
for k in 0..n {
|
||||
let (mut best, mut big) = (k, 0.0);
|
||||
for r in k..n {
|
||||
let x = a[r * n + k].abs();
|
||||
if x > big {
|
||||
(best, big) = (r, x);
|
||||
}
|
||||
}
|
||||
if !(big > scale * 1e-13) {
|
||||
return invalid("singular system");
|
||||
}
|
||||
piv[k] = best;
|
||||
if best != k {
|
||||
for c in 0..n {
|
||||
a.swap(k * n + c, best * n + c);
|
||||
}
|
||||
}
|
||||
let d = a[k * n + k];
|
||||
for r in k + 1..n {
|
||||
let f = a[r * n + k] / d;
|
||||
a[r * n + k] = f;
|
||||
if f != 0.0 {
|
||||
for c in k + 1..n {
|
||||
a[r * n + c] -= f * a[k * n + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Lu { n, a, piv })
|
||||
}
|
||||
|
||||
fn solve<T: Linear>(&self, b: &[T]) -> Vec<T> {
|
||||
let n = self.n;
|
||||
let mut x = b.to_vec();
|
||||
if x.len() != n {
|
||||
return x;
|
||||
}
|
||||
for (k, &p) in self.piv.iter().enumerate() {
|
||||
x.swap(k, p);
|
||||
}
|
||||
for r in 0..n {
|
||||
let row = &self.a[r * n..r * n + r];
|
||||
let acc = row.iter().zip(&x[..r]).fold(x[r], |acc, (l, xc)| acc.plus(xc.times(-l)));
|
||||
x[r] = acc;
|
||||
}
|
||||
for r in (0..n).rev() {
|
||||
let row = &self.a[r * n + r + 1..(r + 1) * n];
|
||||
let acc = row.iter().zip(&x[r + 1..]).fold(x[r], |acc, (u, xc)| acc.plus(xc.times(-u)));
|
||||
x[r] = acc.times(1.0 / self.a[r * n + r]);
|
||||
}
|
||||
x
|
||||
}
|
||||
}
|
||||
|
||||
/// The control values in `to` of the spline with control values `ctrl` in `from`, which `to`
|
||||
/// contains: the same domain, a degree at least as high, and every knot of `from` among those of
|
||||
/// `to` (with a polynomial `from` when the degree rises). Exact: Bézier degree elevation (NURBS
|
||||
/// Book §5.5) to the target degree, then Boehm knot insertion (§5.2) of the knots `to` adds.
|
||||
pub fn embed<T: Linear>(from: &Space, ctrl: &[T], to: &Space) -> SplineResult<Vec<T>> {
|
||||
if ctrl.len() != from.count() {
|
||||
return invalid("control values do not match the space");
|
||||
}
|
||||
let ([a0, b0], [a1, b1]) = (from.domain(), to.domain());
|
||||
if a0 != a1 || b0 != b1 {
|
||||
return invalid("the spaces' domains differ");
|
||||
}
|
||||
let mut p = from.degree();
|
||||
let mut knots = from.knots().to_vec();
|
||||
let mut pts: Vec<T> = ctrl.to_vec();
|
||||
if p > to.degree() {
|
||||
return invalid("the target space's degree is lower");
|
||||
}
|
||||
if p < to.degree() {
|
||||
if from.count() != p + 1 {
|
||||
return Err(SplineError::Unsupported("raising the degree of a spline with interior knots".into()));
|
||||
}
|
||||
while p < to.degree() {
|
||||
pts = elevate_bezier(&pts);
|
||||
p += 1;
|
||||
}
|
||||
knots = vec![a0; p + 1];
|
||||
knots.extend(std::iter::repeat_n(b0, p + 1));
|
||||
}
|
||||
// The knots `to` adds: a merge of the two sorted vectors.
|
||||
let target = to.knots();
|
||||
let mut missing = Vec::new();
|
||||
let mut j = 0;
|
||||
for &x in target {
|
||||
match knots.get(j) {
|
||||
Some(&k) if k == x => j += 1,
|
||||
Some(&k) if k < x => return invalid("a knot of the spline is not in the target space"),
|
||||
_ => missing.push(x),
|
||||
}
|
||||
}
|
||||
if j != knots.len() {
|
||||
return invalid("a knot of the spline is not in the target space");
|
||||
}
|
||||
let mut h: Vec<HPoint> = pts.iter().map(|x| x.to_h()).collect();
|
||||
for x in missing {
|
||||
(knots, h) = insert_knot(p, &knots, &h, x)?;
|
||||
}
|
||||
if knots.as_slice() != target {
|
||||
return invalid("knot insertion did not reach the target space");
|
||||
}
|
||||
Ok(h.into_iter().map(T::from_h).collect())
|
||||
}
|
||||
|
||||
/// A Bézier piece's control values one degree higher, the same polynomial (NURBS Book eq. 5.36):
|
||||
/// `Qᵢ = (i/(p+1))·Pᵢ₋₁ + (1 − i/(p+1))·Pᵢ`.
|
||||
fn elevate_bezier<T: Linear>(pts: &[T]) -> Vec<T> {
|
||||
let p = pts.len().saturating_sub(1);
|
||||
let at = |i: usize| pts.get(i).copied().unwrap_or_else(T::zero);
|
||||
(0..=p + 1)
|
||||
.map(|i| {
|
||||
let a = i as f64 / (p + 1) as f64;
|
||||
let prev = if i == 0 { T::zero() } else { at(i - 1).times(a) };
|
||||
prev.plus(at(i).times(1.0 - a))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A monotone piecewise cubic Hermite function through `(xₖ, yₖ)`, both strictly increasing
|
||||
/// (Fritsch & Carlson): slopes are the weighted harmonic means of the neighbouring secants
|
||||
/// (Fritsch & Butland), the secant at the ends. Its slope is positive everywhere, and through two
|
||||
/// points it is the straight line.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Monotone {
|
||||
x: Vec<f64>,
|
||||
y: Vec<f64>,
|
||||
m: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Monotone {
|
||||
pub fn new(x: Vec<f64>, y: Vec<f64>) -> SplineResult<Monotone> {
|
||||
let n = x.len();
|
||||
if n < 2 || y.len() != n {
|
||||
return invalid("a monotone cubic needs two points or more");
|
||||
}
|
||||
let increasing = |v: &[f64]| v.iter().all(|t| t.is_finite()) && v.windows(2).all(|w| w[0] < w[1]);
|
||||
if !increasing(&x) || !increasing(&y) {
|
||||
return invalid("the points of a monotone cubic must increase");
|
||||
}
|
||||
let h: Vec<f64> = x.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
let d: Vec<f64> = y.windows(2).zip(&h).map(|(w, h)| (w[1] - w[0]) / h).collect();
|
||||
let mut m = Vec::with_capacity(n);
|
||||
m.push(d[0]);
|
||||
for k in 1..n - 1 {
|
||||
let (w1, w2) = (2.0 * h[k] + h[k - 1], h[k] + 2.0 * h[k - 1]);
|
||||
m.push((w1 + w2) / (w1 / d[k - 1] + w2 / d[k]));
|
||||
}
|
||||
m.push(d[n - 2]);
|
||||
Ok(Monotone { x, y, m })
|
||||
}
|
||||
|
||||
/// The value and the first two derivatives at `t` (clamped to the points' range).
|
||||
pub fn eval(&self, t: f64) -> [f64; 3] {
|
||||
let n = self.x.len();
|
||||
let t = t.clamp(self.x[0], self.x[n - 1]);
|
||||
let k = self.x.partition_point(|x| *x <= t).saturating_sub(1).min(n - 2);
|
||||
let (x0, x1, y0, y1, m0, m1) = (self.x[k], self.x[k + 1], self.y[k], self.y[k + 1], self.m[k], self.m[k + 1]);
|
||||
let h = x1 - x0;
|
||||
let s = (t - x0) / h;
|
||||
let (s2, s3) = (s * s, s * s * s);
|
||||
let value = (2.0 * s3 - 3.0 * s2 + 1.0) * y0 + (s3 - 2.0 * s2 + s) * h * m0 + (-2.0 * s3 + 3.0 * s2) * y1 + (s3 - s2) * h * m1;
|
||||
let d1 = ((6.0 * s2 - 6.0 * s) * y0 + (3.0 * s2 - 4.0 * s + 1.0) * h * m0 + (-6.0 * s2 + 6.0 * s) * y1 + (3.0 * s2 - 2.0 * s) * h * m1) / h;
|
||||
let d2 = ((12.0 * s - 6.0) * y0 + (6.0 * s - 4.0) * h * m0 + (-12.0 * s + 6.0) * y1 + (6.0 * s - 2.0) * h * m1) / (h * h);
|
||||
[value, d1, d2]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-10
|
||||
}
|
||||
|
||||
/// Hermite interpolation reproduces a cubic from its values and end derivatives, and the
|
||||
/// lowest-degree interpolant through two values is the straight line.
|
||||
#[test]
|
||||
fn interpolation_meets_values_and_derivatives() {
|
||||
let f = |t: f64| 1.0 + 2.0 * t - 3.0 * t * t + 0.5 * t * t * t;
|
||||
let df = |t: f64| 2.0 - 6.0 * t + 1.5 * t * t;
|
||||
let d2f = |t: f64| -6.0 + 3.0 * t;
|
||||
let conds = vec![Cond { t: 0.0, der: 0 }, Cond { t: 0.0, der: 1 }, Cond { t: 0.0, der: 2 }, Cond { t: 2.0, der: 0 }];
|
||||
let it = Interpolant::lowest(conds, 0.0, 2.0).unwrap();
|
||||
let c = it.fit(&[f(0.0), df(0.0), d2f(0.0), f(2.0)]).unwrap();
|
||||
for k in 0..=10 {
|
||||
let t = k as f64 * 0.2;
|
||||
assert!(close(it.space().eval(&c, t, 0).unwrap(), f(t)), "{t}");
|
||||
}
|
||||
let line = Interpolant::lowest(vec![Cond { t: 0.0, der: 0 }, Cond { t: 1.0, der: 0 }], 0.0, 1.0).unwrap();
|
||||
let c = line.fit(&[2.0, 5.0]).unwrap();
|
||||
assert!(close(line.space().eval(&c, 0.25, 0).unwrap(), 2.75));
|
||||
let constant = Interpolant::lowest(vec![Cond { t: 0.3, der: 0 }], 0.0, 1.0).unwrap();
|
||||
let c = constant.fit(&[4.0, 0.0]).unwrap();
|
||||
assert!(close(constant.space().eval(&c, 0.9, 0).unwrap(), 4.0));
|
||||
}
|
||||
|
||||
/// Many conditions: a cubic spline with averaged knots through samples of a smooth function
|
||||
/// with derivative conditions at both ends (values exact at the samples).
|
||||
#[test]
|
||||
fn spline_interpolation_with_end_derivatives() {
|
||||
let f = |t: f64| (2.0 * t).sin();
|
||||
let df = |t: f64| 2.0 * (2.0 * t).cos();
|
||||
let ts: Vec<f64> = (0..=8).map(|k| k as f64 / 8.0).collect();
|
||||
let mut conds: Vec<Cond> = ts.iter().map(|&t| Cond { t, der: 0 }).collect();
|
||||
conds.push(Cond { t: 0.0, der: 1 });
|
||||
conds.push(Cond { t: 1.0, der: 1 });
|
||||
let it = Interpolant::lowest(conds, 0.0, 1.0).unwrap();
|
||||
let mut data: Vec<f64> = ts.iter().map(|&t| f(t)).collect();
|
||||
data.push(df(0.0));
|
||||
data.push(df(1.0));
|
||||
let c = it.fit(&data).unwrap();
|
||||
for &t in &ts {
|
||||
assert!(close(it.space().eval(&c, t, 0).unwrap(), f(t)));
|
||||
}
|
||||
assert!(close(it.space().eval(&c, 0.0, 1).unwrap(), df(0.0)));
|
||||
assert!(close(it.space().eval(&c, 1.0, 1).unwrap(), df(1.0)));
|
||||
assert!((it.space().eval(&c, 0.53, 0).unwrap() - f(0.53)).abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// Embedding is exact: a quadratic polynomial raised to a cubic with knots inserted, and a
|
||||
/// cubic spline refined, evaluate the same everywhere; a space that does not contain the
|
||||
/// spline is refused.
|
||||
#[test]
|
||||
fn embedding_keeps_the_spline() {
|
||||
let from = Space::polynomial(2, 0.0, 1.0).unwrap();
|
||||
let ctrl = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 2.0, 0.0), Vec3::new(3.0, -1.0, 1.0)];
|
||||
let to = Space::with_breaks(3, &[0.0, 0.2, 0.5, 0.7, 1.0]).unwrap();
|
||||
let out = embed(&from, &ctrl, &to).unwrap();
|
||||
let spline = Space::with_breaks(3, &[0.0, 0.5, 1.0]).unwrap();
|
||||
let sctrl: Vec<f64> = (0..spline.count()).map(|i| (i as f64 * 1.7).sin()).collect();
|
||||
let finer = spline.refined(&[0.25, 0.5, 0.8]).unwrap();
|
||||
let sout = embed(&spline, &sctrl, &finer).unwrap();
|
||||
for k in 0..=20 {
|
||||
let t = k as f64 / 20.0;
|
||||
assert!(from.eval(&ctrl, t, 0).unwrap().dist(to.eval(&out, t, 0).unwrap()) < 1e-12);
|
||||
assert!(close(spline.eval(&sctrl, t, 0).unwrap(), finer.eval(&sout, t, 0).unwrap()));
|
||||
}
|
||||
assert!(embed(&finer, &sout, &spline).is_err(), "a coarser space");
|
||||
assert!(embed(&spline, &sctrl, &Space::with_breaks(3, &[0.0, 0.4, 1.0]).unwrap()).is_err(), "a knot missing");
|
||||
assert!(embed(&spline, &sctrl, &Space::with_breaks(3, &[0.0, 0.5, 2.0]).unwrap()).is_err(), "another domain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotone_cubic_is_monotone_and_straight_through_two_points() {
|
||||
let m = Monotone::new(vec![0.0, 0.3, 0.5, 1.0], vec![0.0, 0.05, 0.9, 1.0]).unwrap();
|
||||
let mut last = -1.0;
|
||||
for k in 0..=200 {
|
||||
let [y, d, _] = m.eval(k as f64 / 200.0);
|
||||
assert!(y >= last && d >= 0.0, "{k}");
|
||||
last = y;
|
||||
}
|
||||
assert!(close(m.eval(0.3)[0], 0.05) && close(m.eval(0.5)[0], 0.9));
|
||||
let line = Monotone::new(vec![0.0, 1.0], vec![2.0, 4.0]).unwrap();
|
||||
assert!(close(line.eval(0.25)[0], 2.5) && close(line.eval(0.7)[1], 2.0) && close(line.eval(0.7)[2], 0.0));
|
||||
assert!(Monotone::new(vec![0.0, 0.0], vec![0.0, 1.0]).is_err());
|
||||
assert!(Monotone::new(vec![0.0, 1.0], vec![1.0, 0.0]).is_err());
|
||||
assert!(Monotone::new(vec![0.0, f64::NAN], vec![0.0, 1.0]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_spaces_are_refused() {
|
||||
assert!(Space::new(3, vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]).is_ok());
|
||||
assert!(Space::new(3, vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]).is_err(), "too few knots");
|
||||
assert!(Space::new(3, vec![0.0, 0.0, 0.0, 0.1, 1.0, 1.0, 1.0, 1.0]).is_err(), "not clamped");
|
||||
assert!(Space::new(1, vec![0.0, 0.0, 0.5, 0.5, 1.0, 1.0]).is_err(), "a discontinuous space");
|
||||
assert!(Space::new(0, vec![0.0, 1.0]).is_err());
|
||||
assert!(Space::with_breaks(3, &[0.0, 0.5, 0.5, 1.0]).is_err());
|
||||
assert!(Space::with_breaks(3, &[0.0, f64::NAN, 1.0]).is_err());
|
||||
let s = Space::with_breaks(3, &[0.0, 1.0]).unwrap();
|
||||
assert!(s.eval(&[1.0, 2.0], 0.5, 0).is_err(), "wrong control count");
|
||||
assert!(s.basis(f64::NAN, 0).is_err());
|
||||
// Repeated conditions do not determine a spline.
|
||||
assert!(Interpolant::new(s.clone(), vec![Cond { t: 0.5, der: 0 }; 4]).is_err());
|
||||
assert!(Interpolant::lowest(vec![Cond { t: 2.0, der: 0 }], 0.0, 1.0).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1326,6 +1326,10 @@ impl Body {
|
||||
/// The reference mesher's angular display mesh: what [`Body::display_mesh_angular`] falls
|
||||
/// back to when no plan can be made.
|
||||
pub fn reference_display_mesh_angular(&self, tol: f64, angle: f64) -> Result<Mesh> {
|
||||
crate::profile::timed(crate::profile::Kind::Tessellate, || self.reference_display_mesh_angular_untimed(tol, angle))
|
||||
}
|
||||
|
||||
fn reference_display_mesh_angular_untimed(&self, tol: f64, angle: f64) -> Result<Mesh> {
|
||||
if let Some((shared, t)) = self.display_of.as_deref()
|
||||
&& let Some(placed) =
|
||||
shared.mesh.get_or_init(|| shared.original.reference_display_mesh_angular(tol, angle).ok()).as_ref().and_then(|m| placed_mesh(m, t))
|
||||
|
||||
@@ -501,3 +501,26 @@ fn bodies_apart_joined_keep_their_faces() {
|
||||
}
|
||||
assert!(faces_of(h).iter().all(|(change, origins)| *change == Change::Kept && origins.len() == 1), "{}", show(h));
|
||||
}
|
||||
|
||||
/// A plate with a round end and a boss of that radius standing on it (#1209): the boss's wall
|
||||
/// and the plate's round end are two pieces of one cylinder, one over the other, meeting along a
|
||||
/// circle at the plate's top. That circle crosses the walls at its level only, so a second boss
|
||||
/// joined to the plate and a cut through the first, both prism booleans sewn slab by slab, keep
|
||||
/// every face of the plate and its boss, with their history.
|
||||
#[test]
|
||||
fn stacked_pieces_of_one_cylinder_keep_their_walls() {
|
||||
let plate =
|
||||
boolean(&boxed(0.0, -6.0, 0.0, 40.0, 6.0, 5.0), &crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 5.0).unwrap(), BoolOp::Union).unwrap().unwrap();
|
||||
let arm = boolean(&plate, &crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 8.0).unwrap(), BoolOp::Union).unwrap().unwrap();
|
||||
verified(&arm, &[&plate, &crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 8.0).unwrap()], 0.01);
|
||||
// Standing over the plate's far end, as the boss at the arm's other end does.
|
||||
let second = crate::cylinder(Vec3::new(40.0, 0.0, 0.0), Vec3::Z, 4.0, 7.0).unwrap();
|
||||
let bore = boxed(-3.0, -3.0, -1.0, 3.0, 3.0, 10.0);
|
||||
for (tool, op) in [(&second, BoolOp::Union), (&bore, BoolOp::Cut)] {
|
||||
let r = boolean(&arm, tool, op).unwrap().unwrap();
|
||||
let h = verified(&r, &[&arm, tool], 0.01);
|
||||
for f in 0..arm.face_count() {
|
||||
assert!(!from(h, face(0, f)).is_empty(), "{op:?}: face {f} of the plate and boss is lost\n{}", show(h));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,16 @@
|
||||
//! surface — its tangent plane, its curvature, the change of that curvature — as the side's order
|
||||
//! asks for, with `crown` shaping the middle without touching a boundary condition.
|
||||
//!
|
||||
//! A patch through *three or four* boundaries is refused rather than built approximately. At a
|
||||
//! corner two sides prescribe the same entries of the control net from two different surfaces, and
|
||||
//! a tensor-product patch can hold both only where those two prescriptions already agree (one wall
|
||||
//! carrying every side, say). Where they do not — the general case — the requested order would
|
||||
//! A patch through *three or four* boundaries is refused here rather than built approximately. At
|
||||
//! a corner two sides prescribe the same entries of the control net from two different surfaces,
|
||||
//! and a tensor-product patch can hold both only where those two prescriptions already agree (one
|
||||
//! wall carrying every side, say). Where they do not — the general case — the requested order would
|
||||
//! silently drop near the corner, or the boundary curve would visibly leave its stations, and a
|
||||
//! Gregory-type corner (a rational blend of the sides' own data) is needed to hold them instead
|
||||
//! (issue #763). Everything this module refuses is an error, never a quietly worse surface.
|
||||
//! (issue #763). A loop of one sketch's curves is the case where they agree: it lies in the
|
||||
//! sketch's plane, and its flat Coons patch ([`crate::boundary_network`]) holds every order
|
||||
//! exactly; the network there refuses G1/G2 on two sides that meet at a corner for the same
|
||||
//! reason. Everything this module refuses is an error, never a quietly worse surface.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
//! Boundary surfaces (Surface › Boundary): one B-spline face through curves in one or two
|
||||
//! directions (`solidcraft_geom::network`: a loft between curves, a Coons patch inside four, a
|
||||
//! Gordon surface through a network), made from sketch curves, 3D sketch curves or a body's
|
||||
//! edges. A boundary curve that is a chain of open edges of a surface may carry a continuity
|
||||
//! condition with the face beside it: G1 (tangent) or G2 (curvature).
|
||||
//!
|
||||
//! The reusable pieces: [`CurveSource`] turns each kind of curve into a curve the network can
|
||||
//! measure, [`EdgeFaces`] gives the frame and second fundamental form of the faces along a chain
|
||||
//! of edges (what a G1 or G2 condition needs), and [`boundary_surface`] builds the face.
|
||||
//!
|
||||
//! The two-curve bridge with G0–G3 against a sketch's plane is [`crate::boundary`]; this is the
|
||||
//! general case: three or four sides, interior curves, 3D curves and edges.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use mt::{BoundedCurve, Cut, InnerSpace, ParametricCurve, ParametricSurface, SearchNearestParameter};
|
||||
use solidcraft_geom::bspline::{CurveN, NurbsSurface, SplineError};
|
||||
use solidcraft_geom::network::{self, CrossFace, FaceFrame, NetCurve, NetworkCurve, PointCurve, Tolerances};
|
||||
use solidcraft_geom::{Plane, Seg2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3, p3};
|
||||
use crate::bridge::Continuity;
|
||||
use crate::plane_fit::EdgeSampler;
|
||||
use crate::record::Rebuild;
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// How far apart curves of the two directions may pass and still cross (mm).
|
||||
pub const CROSS_TOLERANCE: f64 = 0.01;
|
||||
/// How far the surface may stray from its curves (mm).
|
||||
pub const FIT_TOLERANCE: f64 = 1e-4;
|
||||
/// Most points of a 3D sketch curve a boundary surface follows (longer ones are resampled).
|
||||
const MAX_WIRE_POINTS: usize = 400;
|
||||
/// Most points picking the edges of one curve.
|
||||
pub const MAX_EDGE_PICKS: usize = 10_000;
|
||||
/// Most sketch curves one curve of a boundary surface chains.
|
||||
pub const MAX_CHAIN_CURVES: usize = 1000;
|
||||
|
||||
/// The network's continuity for a curve's: G3 is refused (a network holds up to G2).
|
||||
fn net_order(c: &BoundaryCurve) -> Result<network::Continuity> {
|
||||
match c.continuity {
|
||||
Continuity::G0 => Ok(network::Continuity::G0),
|
||||
Continuity::G1 => Ok(network::Continuity::G1),
|
||||
Continuity::G2 => Ok(network::Continuity::G2),
|
||||
Continuity::G3 => Err(KernelError::Invalid(format!("not supported yet: G3 continuity on {} (a curve network holds up to G2)", c.label))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a curve of a boundary surface comes from.
|
||||
pub enum CurveSource<'a> {
|
||||
/// Sketch segments on a plane, each starting where the one before it ends.
|
||||
Sketch { plane: Plane, segs: Vec<Seg2> },
|
||||
/// A 3D sketch curve's points: followed smoothly through them (a cubic spline).
|
||||
Points(Vec<Vec3>),
|
||||
/// A chain of a body's edges, each picked by a point on it.
|
||||
Edges { body: &'a Body, at: Vec<Vec3> },
|
||||
/// A side collapsed to a point: the apex of a three-sided patch, where the sides beside it
|
||||
/// meet.
|
||||
Point(Vec3),
|
||||
}
|
||||
|
||||
/// A curve of a boundary surface: a name for messages, where it comes from, and (for edges of a
|
||||
/// surface) its continuity with the face beside it.
|
||||
pub struct BoundaryCurve<'a> {
|
||||
pub label: String,
|
||||
pub source: CurveSource<'a>,
|
||||
pub continuity: Continuity,
|
||||
}
|
||||
|
||||
/// One B-spline face through curves `u` (two or more; or one with a continuity condition, crossed
|
||||
/// by `v`) and `v` (none: a loft through `u`; two each way and no more: a Coons patch; more: a
|
||||
/// Gordon surface through the network). A surface body; its history has no operands (the face
|
||||
/// and its edges are new). Refused, naming the curves: curves that miss each other (by more than
|
||||
/// [`CROSS_TOLERANCE`]) or cross twice, curves out of order, closed curves, a continuity
|
||||
/// condition on anything but an open edge of a surface, or on curves of both directions.
|
||||
pub fn boundary_surface(u: &[BoundaryCurve], v: &[BoundaryCurve]) -> Result<Body> {
|
||||
for c in u.iter().chain(v) {
|
||||
if let CurveSource::Edges { body, .. } = &c.source {
|
||||
body.require_brep("boundary surface")?;
|
||||
}
|
||||
}
|
||||
guard("boundary surface", || {
|
||||
let mut corners: Vec<Vec3> = Vec::new();
|
||||
let mut to_net = |c: &BoundaryCurve| -> Result<NetworkCurve<'static>> {
|
||||
let (curve, face): (Box<dyn NetCurve>, Option<EdgeFaces>) = match &c.source {
|
||||
CurveSource::Sketch { plane, segs } => (Box::new(SketchChain::new(&c.label, plane, segs)?), None),
|
||||
CurveSource::Points(pts) => (Box::new(Polyline::new(&c.label, pts)?), None),
|
||||
CurveSource::Point(p) => {
|
||||
if !p.is_finite() {
|
||||
return Err(KernelError::Invalid(format!("{} is not a finite point", c.label)));
|
||||
}
|
||||
(Box::new(PointCurve(*p)), None)
|
||||
}
|
||||
CurveSource::Edges { body, at } => {
|
||||
if at.is_empty() || at.len() > MAX_EDGE_PICKS || at.iter().any(|p| !p.is_finite()) {
|
||||
return Err(KernelError::Invalid(format!("{}: pick 1 to {MAX_EDGE_PICKS} points on its edges", c.label)));
|
||||
}
|
||||
let (chain, closed) = crate::surfaces::picked_chain(body, at)?;
|
||||
if closed {
|
||||
return Err(KernelError::Invalid(format!("not supported yet: {} is a closed chain of edges", c.label)));
|
||||
}
|
||||
// The chain's inner vertices: the face's sides are split there, so that a
|
||||
// join shares the body's edges one for one.
|
||||
corners.extend(chain.iter().skip(1).map(|e| from_p3(e.front().point())));
|
||||
let face = match c.continuity {
|
||||
Continuity::G0 => None,
|
||||
_ => Some(EdgeFaces::new(&c.label, body, &chain)?),
|
||||
};
|
||||
(Box::new(EdgeChain::new(&c.label, &chain)?), face)
|
||||
}
|
||||
};
|
||||
if c.continuity != Continuity::G0 && face.is_none() {
|
||||
return Err(KernelError::Invalid(format!("{}: G1 and G2 apply only to a body's edges", c.label)));
|
||||
}
|
||||
let order = net_order(c)?;
|
||||
Ok(NetworkCurve { label: c.label.clone(), curve, condition: face.map(|f| (order, Box::new(f) as Box<dyn CrossFace>)) })
|
||||
};
|
||||
let nu: Vec<NetworkCurve> = u.iter().map(&mut to_net).collect::<Result<_>>()?;
|
||||
let nv: Vec<NetworkCurve> = v.iter().map(&mut to_net).collect::<Result<_>>()?;
|
||||
let surface = network::network_surface(nu, nv, Tolerances { cross: CROSS_TOLERANCE, fit: FIT_TOLERANCE }).map_err(|e| match e {
|
||||
SplineError::Unsupported(m) => KernelError::Invalid(format!("not supported yet: {m}")),
|
||||
SplineError::TooLarge(m) => KernelError::TooLarge(m),
|
||||
SplineError::Invalid(m) => KernelError::Invalid(m),
|
||||
})?;
|
||||
let face = face_of(&surface, &corners)?;
|
||||
let mut rec = Rebuild::default();
|
||||
rec.made(&face, Vec::new());
|
||||
crate::surfaces::body_of(vec![face], &[], &rec)
|
||||
})
|
||||
}
|
||||
|
||||
/// `made` (a boundary surface) sewn into `target` along the edges they share: one body, a solid
|
||||
/// when it closes the gap. Refused when they share no edge.
|
||||
pub fn boundary_join(target: &Body, made: &Body) -> Result<Body> {
|
||||
target.require_brep("join")?;
|
||||
let edges = |b: &Body| b.solid.edge_iter().map(|e| e.id()).collect::<HashSet<_>>().len();
|
||||
let joined = crate::surfaces::stitch(&[target, made], CROSS_TOLERANCE)?;
|
||||
if edges(&joined) == edges(target) + edges(made) {
|
||||
return Err(KernelError::Invalid("the surface shares no edge with the body it joins".into()));
|
||||
}
|
||||
Ok(joined)
|
||||
}
|
||||
|
||||
/// Sketch segments on a plane, run end to end by arc length.
|
||||
struct SketchChain {
|
||||
plane: Plane,
|
||||
segs: Vec<Seg2>,
|
||||
/// Cumulative length at the start of each segment, and the total.
|
||||
starts: Vec<f64>,
|
||||
total: f64,
|
||||
}
|
||||
|
||||
impl SketchChain {
|
||||
fn new(label: &str, plane: &Plane, segs: &[Seg2]) -> Result<SketchChain> {
|
||||
if segs.is_empty() || segs.len() > MAX_CHAIN_CURVES {
|
||||
return Err(KernelError::Invalid(format!("{label} has no curves, or too many")));
|
||||
}
|
||||
if segs.windows(2).any(|w| w[0].end().dist(w[1].start()) > 1e-6) {
|
||||
return Err(KernelError::Invalid(format!("{label}: its curves don't meet end to end")));
|
||||
}
|
||||
let mut starts = Vec::with_capacity(segs.len());
|
||||
let mut total = 0.0;
|
||||
for s in segs {
|
||||
starts.push(total);
|
||||
total += s.length();
|
||||
}
|
||||
if !(total > 0.0 && total.is_finite()) {
|
||||
return Err(KernelError::Invalid(format!("{label} has no length")));
|
||||
}
|
||||
Ok(SketchChain { plane: *plane, segs: segs.to_vec(), starts, total })
|
||||
}
|
||||
}
|
||||
|
||||
impl NetCurve for SketchChain {
|
||||
fn point(&mut self, t: f64) -> Option<Vec3> {
|
||||
let at = t.clamp(0.0, 1.0) * self.total;
|
||||
let k = self.starts.partition_point(|s| *s <= at).saturating_sub(1);
|
||||
let (seg, start) = (self.segs.get(k)?, self.starts.get(k)?);
|
||||
let len = seg.length();
|
||||
let local = if len > 0.0 { ((at - start) / len).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let p = self.plane.to_world(seg.point_at(local));
|
||||
p.is_finite().then_some(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3D polyline followed smoothly: a cubic through its points by chord length (fewer points, a
|
||||
/// lower degree; a long one resampled first).
|
||||
struct Polyline {
|
||||
curve: CurveN<3>,
|
||||
end: f64,
|
||||
}
|
||||
|
||||
impl Polyline {
|
||||
fn new(label: &str, pts: &[Vec3]) -> Result<Polyline> {
|
||||
let mut clean: Vec<Vec3> = Vec::with_capacity(pts.len());
|
||||
for p in pts {
|
||||
if !p.is_finite() {
|
||||
return Err(KernelError::Invalid(format!("{label} has a point that is not a finite number")));
|
||||
}
|
||||
if clean.last().is_none_or(|q: &Vec3| q.dist(*p) > 1e-9) {
|
||||
clean.push(*p);
|
||||
}
|
||||
}
|
||||
let pts = resample(&clean, MAX_WIRE_POINTS);
|
||||
if pts.len() < 2 {
|
||||
return Err(KernelError::Invalid(format!("{label} has no length")));
|
||||
}
|
||||
let mut params = vec![0.0];
|
||||
for w in pts.windows(2) {
|
||||
params.push(params.last().copied().unwrap_or(0.0) + w[0].dist(w[1]));
|
||||
}
|
||||
let end = params.last().copied().unwrap_or(0.0);
|
||||
let data: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect();
|
||||
let curve = CurveN::interpolate(¶ms, &data, (pts.len() - 1).min(3))
|
||||
.map_err(|e| KernelError::Invalid(format!("{label} can't be followed smoothly: {e}")))?;
|
||||
Ok(Polyline { curve, end })
|
||||
}
|
||||
}
|
||||
|
||||
impl NetCurve for Polyline {
|
||||
fn point(&mut self, t: f64) -> Option<Vec3> {
|
||||
let ([x, y, z], _) = self.curve.eval(t.clamp(0.0, 1.0) * self.end)?;
|
||||
Some(Vec3::new(x, y, z))
|
||||
}
|
||||
}
|
||||
|
||||
/// At most `max` points evenly along a polyline by arc length, its ends kept.
|
||||
fn resample(pts: &[Vec3], max: usize) -> Vec<Vec3> {
|
||||
if pts.len() <= max || max < 2 {
|
||||
return pts.to_vec();
|
||||
}
|
||||
let mut cum = vec![0.0];
|
||||
for w in pts.windows(2) {
|
||||
cum.push(cum.last().copied().unwrap_or(0.0) + w[0].dist(w[1]));
|
||||
}
|
||||
let total = cum.last().copied().unwrap_or(0.0);
|
||||
(0..max)
|
||||
.filter_map(|k| {
|
||||
let at = total * k as f64 / (max - 1) as f64;
|
||||
let i = cum.partition_point(|c| *c <= at).saturating_sub(1).min(pts.len().saturating_sub(2));
|
||||
let (c0, c1) = (cum.get(i)?, cum.get(i + 1)?);
|
||||
let w = if c1 > c0 { ((at - c0) / (c1 - c0)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
Some(pts.get(i)?.lerp(*pts.get(i + 1)?, w))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A chain of edges (each turned to run along it), by their lengths.
|
||||
struct EdgeChain {
|
||||
edges: Vec<EdgeSampler>,
|
||||
starts: Vec<f64>,
|
||||
total: f64,
|
||||
}
|
||||
|
||||
impl EdgeChain {
|
||||
fn new(label: &str, chain: &[mt::Edge]) -> Result<EdgeChain> {
|
||||
let mut edges = Vec::with_capacity(chain.len());
|
||||
let mut starts = Vec::with_capacity(chain.len());
|
||||
let mut total = 0.0;
|
||||
for e in chain {
|
||||
let mut s = EdgeSampler::new(e).ok_or_else(|| KernelError::Invalid(format!("{label}: an edge has no parameter range")))?;
|
||||
let mut len = 0.0;
|
||||
let mut last: Option<Vec3> = None;
|
||||
for k in 0..=16 {
|
||||
let p = s.at(k as f64 / 16.0).ok_or_else(|| KernelError::Invalid(format!("{label}: an edge can't be evaluated")))?;
|
||||
if let Some(q) = last {
|
||||
len += p.dist(q);
|
||||
}
|
||||
last = Some(p);
|
||||
}
|
||||
starts.push(total);
|
||||
total += len;
|
||||
edges.push(s);
|
||||
}
|
||||
if !(total > 0.0 && total.is_finite()) {
|
||||
return Err(KernelError::Invalid(format!("{label} has no length")));
|
||||
}
|
||||
Ok(EdgeChain { edges, starts, total })
|
||||
}
|
||||
}
|
||||
|
||||
impl NetCurve for EdgeChain {
|
||||
fn point(&mut self, t: f64) -> Option<Vec3> {
|
||||
let at = t.clamp(0.0, 1.0) * self.total;
|
||||
let k = self.starts.partition_point(|s| *s <= at).saturating_sub(1);
|
||||
let start = *self.starts.get(k)?;
|
||||
let end = self.starts.get(k + 1).copied().unwrap_or(self.total);
|
||||
let local = if end > start { ((at - start) / (end - start)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
self.edges.get_mut(k)?.at(local)
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces along a chain of edges, each edge an open edge of one face of its body: their
|
||||
/// frames and second fundamental forms where a boundary surface meets them (G1 and G2
|
||||
/// conditions).
|
||||
pub(crate) struct EdgeFaces {
|
||||
sides: Vec<EdgeSide>,
|
||||
}
|
||||
|
||||
/// One edge of the chain: points along it (to find the edge nearest a point), the surface of
|
||||
/// its face, whether the face's boundary runs along the chain (`along`), and the surface
|
||||
/// parameters found last (the next search starts there).
|
||||
struct EdgeSide {
|
||||
pts: Vec<Vec3>,
|
||||
surface: mt::Surface,
|
||||
along: bool,
|
||||
hint: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl EdgeFaces {
|
||||
pub(crate) fn new(label: &str, body: &Body, chain: &[mt::Edge]) -> Result<EdgeFaces> {
|
||||
let mut sides = Vec::with_capacity(chain.len());
|
||||
for e in chain {
|
||||
let mut faces = body.solid.face_iter().filter(|f| f.boundaries().iter().flatten().any(|x| x.id() == e.id()));
|
||||
let (Some(face), None) = (faces.next(), faces.next()) else {
|
||||
return Err(KernelError::Invalid(format!(
|
||||
"{label}: G1 and G2 need an open edge of a surface (an edge with a face on one side only)"
|
||||
)));
|
||||
};
|
||||
// How the face's own (absolute) boundary runs this edge.
|
||||
let in_wire = face.absolute_boundaries().iter().flatten().find(|x| x.id() == e.id()).cloned();
|
||||
let in_wire = in_wire.ok_or_else(|| KernelError::Failed("boundary surface: an edge's face".into()))?;
|
||||
let along = in_wire.front().id() == e.front().id();
|
||||
let mut sampler = EdgeSampler::new(e).ok_or_else(|| KernelError::Invalid(format!("{label}: an edge has no parameter range")))?;
|
||||
let pts = (0..=16).map(|k| sampler.at(k as f64 / 16.0)).collect::<Option<Vec<_>>>();
|
||||
let pts = pts.ok_or_else(|| KernelError::Invalid(format!("{label}: an edge can't be evaluated")))?;
|
||||
sides.push(EdgeSide { pts, surface: face.surface(), along, hint: None });
|
||||
}
|
||||
Ok(EdgeFaces { sides })
|
||||
}
|
||||
}
|
||||
|
||||
impl CrossFace for EdgeFaces {
|
||||
fn frame(&mut self, at: Vec3, tangent: Vec3) -> Option<FaceFrame> {
|
||||
// The edge nearest the point, and the chain's direction along it there.
|
||||
let near = |s: &EdgeSide| s.pts.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||||
let k = (0..self.sides.len()).min_by(|a, b| near(&self.sides[*a]).total_cmp(&near(&self.sides[*b])))?;
|
||||
let side = self.sides.get_mut(k)?;
|
||||
let seg = side.pts.windows(2).min_by(|a, b| at.dist_to_segment(a[0], a[1]).total_cmp(&at.dist_to_segment(b[0], b[1])))?;
|
||||
let chain_dir = seg[1] - seg[0];
|
||||
let s = &side.surface;
|
||||
let (u, v) = s.search_nearest_parameter(p3(at), side.hint, 100).or_else(|| s.search_nearest_parameter(p3(at), None, 100))?;
|
||||
side.hint = Some((u, v));
|
||||
let (su, sv) = (s.uder(u, v), s.vder(u, v));
|
||||
let n = su.cross(sv);
|
||||
if !(n.magnitude2() > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let n = n.normalize();
|
||||
let normal = Vec3::new(n.x, n.y, n.z);
|
||||
// The face's boundary runs counter-clockwise about its surface's normal, so the face lies
|
||||
// to the left of it: outward is the boundary's direction × the normal.
|
||||
let wire = if side.along { chain_dir } else { -chain_dir };
|
||||
let outward = wire.cross(normal).normalized()?;
|
||||
// The second fundamental form, in the basis of the tangent asked about and outward.
|
||||
let t = (tangent - normal * tangent.dot(normal)).normalized()?;
|
||||
let b = (outward - t * outward.dot(t)).normalized()?;
|
||||
let (e, f, g) = (su.dot(su), su.dot(sv), sv.dot(sv));
|
||||
let det = e * g - f * f;
|
||||
if !(det.abs() > 1e-24) {
|
||||
return None;
|
||||
}
|
||||
// II(w, w) = L·α² + 2M·αβ + N·β² for w = α·S_u + β·S_v, with L, M, N the surface's
|
||||
// second derivatives along its unit normal (do Carmo, §3-3): exact, rational surfaces
|
||||
// included, and with nothing sampled beyond the edge, where the face's domain ends.
|
||||
let nv = mt::Vector3::new(normal.x, normal.y, normal.z);
|
||||
let (l, m, nn) = (s.uuder(u, v).dot(nv), s.uvder(u, v).dot(nv), s.vvder(u, v).dot(nv));
|
||||
let ii = |w: Vec3| -> Option<f64> {
|
||||
let wv = mt::Vector3::new(w.x, w.y, w.z);
|
||||
let (p, q) = (wv.dot(su), wv.dot(sv));
|
||||
let (alpha, beta) = ((p * g - q * f) / det, (q * e - p * f) / det);
|
||||
let out = l * alpha * alpha + 2.0 * m * alpha * beta + nn * beta * beta;
|
||||
out.is_finite().then_some(out)
|
||||
};
|
||||
let (tt, bb) = (ii(t)?, ii(b)?);
|
||||
let tb = (ii(t + b)? - tt - bb) / 2.0;
|
||||
let form = [tt, tb, bb];
|
||||
form.iter().all(|x| x.is_finite()).then_some(FaceFrame { normal, outward: b, form })
|
||||
}
|
||||
}
|
||||
|
||||
/// The face of a network surface: its four sides from the surface's boundary rows, each split
|
||||
/// at the `corners` that lie on it (inner vertices of edge chains). Refused when a side shrinks to
|
||||
/// a point.
|
||||
fn face_of(s: &NurbsSurface, corners: &[Vec3]) -> Result<mt::Face> {
|
||||
let fail = |m: &str| KernelError::Failed(format!("boundary surface: {m}"));
|
||||
let [nu, nv] = s.count();
|
||||
let [ku, kv] = s.knots();
|
||||
let rows: Vec<Vec<mt::Point3>> = (0..nu).map(|i| (0..nv).map(|j| p3(s.point(i, j).xyz())).collect()).collect();
|
||||
let (uk, vk) = (mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone()));
|
||||
let surface = mt::BSplineSurface::try_new((uk.clone(), vk.clone()), rows.clone()).map_err(|e| fail(&e.to_string()))?;
|
||||
let row = |pts: Vec<mt::Point3>, k: &mt::KnotVec| mt::BSplineCurve::try_new(k.clone(), pts).map_err(|e| fail(&e.to_string()));
|
||||
let (Some(first), Some(last)) = (rows.first(), rows.last()) else { return Err(fail("control net")) };
|
||||
let bottom = row(rows.iter().filter_map(|r| r.first().copied()).collect(), &uk)?;
|
||||
let top = row(rows.iter().filter_map(|r| r.last().copied()).collect(), &uk)?;
|
||||
let left = row(first.clone(), &vk)?;
|
||||
let right = row(last.clone(), &vk)?;
|
||||
let corner = |c: &mt::BSplineCurve<mt::Point3>, end: bool| {
|
||||
let (t0, t1) = c.range_tuple();
|
||||
c.subs(if end { t1 } else { t0 })
|
||||
};
|
||||
let (c00, c10, c11, c01) = (corner(&bottom, false), corner(&bottom, true), corner(&top, true), corner(&top, false));
|
||||
let size = [c00, c10, c11, c01].iter().map(|p| from_p3(*p).dist(from_p3(c00))).fold(0.0, f64::max).max(1.0);
|
||||
// A side collapsed to a point (a three-sided patch) is left out of the loop: the two sides
|
||||
// beside it meet at one vertex there.
|
||||
let sides = [(c00, c10), (c10, c11), (c01, c11), (c00, c01)];
|
||||
let collapsed: Vec<bool> = sides.iter().map(|(a, b)| from_p3(*a).dist(from_p3(*b)) <= size * 1e-9).collect();
|
||||
match collapsed.iter().filter(|c| **c).count() {
|
||||
0 | 1 => {}
|
||||
_ => return Err(KernelError::Invalid("not supported yet: a boundary surface with more than one side shrunk to a point".into())),
|
||||
}
|
||||
let mut verts: Vec<mt::Vertex> = Vec::with_capacity(4);
|
||||
for c in [c00, c10, c11, c01] {
|
||||
let same = verts.iter().find(|v| from_p3(v.point()).dist(from_p3(c)) <= size * 1e-9).cloned();
|
||||
verts.push(same.unwrap_or_else(|| mt::Vertex::new(c)));
|
||||
}
|
||||
let (Some(v00), Some(v10), Some(v11), Some(v01)) = (verts.first(), verts.get(1), verts.get(2), verts.get(3)) else {
|
||||
return Err(fail("corners"));
|
||||
};
|
||||
let is = |k: usize| collapsed.get(k).copied().unwrap_or(false);
|
||||
let mut wire: Vec<mt::Edge> = Vec::new();
|
||||
if !is(0) {
|
||||
wire.extend(side_edges(bottom, v00, v10, corners));
|
||||
}
|
||||
if !is(1) {
|
||||
wire.extend(side_edges(right, v10, v11, corners));
|
||||
}
|
||||
if !is(2) {
|
||||
wire.extend(side_edges(top, v01, v11, corners).into_iter().rev().map(|e| e.inverse()));
|
||||
}
|
||||
if !is(3) {
|
||||
wire.extend(side_edges(left, v00, v01, corners).into_iter().rev().map(|e| e.inverse()));
|
||||
}
|
||||
// u along the bottom, v up the left: counter-clockwise in (u, v).
|
||||
mt::Face::try_new(vec![wire.into()], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))
|
||||
}
|
||||
|
||||
/// The edges of one side from `a` to `b`, split at the points of `corners` that lie on it
|
||||
/// (within [`CROSS_TOLERANCE`]) strictly between its ends.
|
||||
fn side_edges(mut curve: mt::BSplineCurve<mt::Point3>, a: &mt::Vertex, b: &mt::Vertex, corners: &[Vec3]) -> Vec<mt::Edge> {
|
||||
let (t0, t1) = curve.range_tuple();
|
||||
let margin = (t1 - t0) * 1e-6;
|
||||
let mut cuts: Vec<f64> = corners
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let t = curve.search_nearest_parameter(p3(*c), None, 100)?;
|
||||
(t > t0 + margin && t < t1 - margin && from_p3(curve.subs(t)).dist(*c) <= CROSS_TOLERANCE).then_some(t)
|
||||
})
|
||||
.collect();
|
||||
cuts.sort_by(f64::total_cmp);
|
||||
cuts.dedup_by(|x, y| (*x - *y).abs() <= margin);
|
||||
let mut edges = Vec::with_capacity(cuts.len() + 1);
|
||||
let mut from = a.clone();
|
||||
// Cut from the far end back, so the earlier parameters stay on the part kept.
|
||||
let mut tails = Vec::with_capacity(cuts.len());
|
||||
for &t in cuts.iter().rev() {
|
||||
tails.push(curve.cut(t));
|
||||
}
|
||||
let mut pieces = vec![curve];
|
||||
pieces.extend(tails.into_iter().rev());
|
||||
let count = pieces.len();
|
||||
for (k, piece) in pieces.into_iter().enumerate() {
|
||||
let to = if k + 1 == count { b.clone() } else { mt::Vertex::new(piece.subs(piece.range_tuple().1)) };
|
||||
edges.push(mt::Edge::new(&from, &to, mt::Curve::BSplineCurve(piece)));
|
||||
from = to;
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::patch_region;
|
||||
use mt::ParametricSurface3D;
|
||||
use solidcraft_geom::{Loop2, Region2, Vec2};
|
||||
|
||||
fn sketch(label: &str, plane: Plane, segs: Vec<Seg2>) -> BoundaryCurve<'static> {
|
||||
BoundaryCurve { label: label.into(), source: CurveSource::Sketch { plane, segs }, continuity: Continuity::G0 }
|
||||
}
|
||||
|
||||
fn line3(label: &str, a: Vec3, b: Vec3) -> BoundaryCurve<'static> {
|
||||
BoundaryCurve { label: label.into(), source: CurveSource::Points(vec![a, b]), continuity: Continuity::G0 }
|
||||
}
|
||||
|
||||
fn face_surface(b: &Body) -> mt::Surface {
|
||||
b.solid.face_iter().next().unwrap().surface()
|
||||
}
|
||||
|
||||
/// The flat sheet y ∈ [−10, 0], x ∈ [0, 10] in z = 0, its edge along y = 0 open.
|
||||
fn sheet() -> Body {
|
||||
let sq = Loop2::polygon(&[Vec2::new(0.0, -10.0), Vec2::new(10.0, -10.0), Vec2::new(10.0, 0.0), Vec2::new(0.0, 0.0)]);
|
||||
patch_region(&Plane::XY, &Region2 { outer: sq, holes: vec![] }).unwrap()
|
||||
}
|
||||
|
||||
/// A Coons patch of four lines bounding the hyperbolic paraboloid z = x·y/10: every point of
|
||||
/// the face lies on it, and its area is the paraboloid's over the square.
|
||||
#[test]
|
||||
fn coons_patch_of_lines_is_a_hyperbolic_paraboloid() {
|
||||
let p = |x: f64, y: f64| Vec3::new(x, y, x * y / 10.0);
|
||||
let body = boundary_surface(
|
||||
&[line3("u0", p(0.0, 0.0), p(10.0, 0.0)), line3("u1", p(0.0, 10.0), p(10.0, 10.0))],
|
||||
&[line3("v0", p(0.0, 0.0), p(0.0, 10.0)), line3("v1", p(10.0, 0.0), p(10.0, 10.0))],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(body.is_surface() && body.face_count() == 1);
|
||||
let s = face_surface(&body);
|
||||
for i in 0..=10 {
|
||||
for j in 0..=10 {
|
||||
let q = from_p3(s.subs(i as f64 / 10.0, j as f64 / 10.0));
|
||||
assert!((q.z - q.x * q.y / 10.0).abs() < 1e-9, "{q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch curves: a patch inside two arcs and two lines on one plane lies in that plane.
|
||||
#[test]
|
||||
fn coplanar_sketch_curves_give_a_plane() {
|
||||
use std::f64::consts::PI;
|
||||
// Two half-discs' arcs over [0, 10] (radius 5) facing out, joined by the lines x = 0, 10.
|
||||
let low = Seg2::Arc { center: Vec2::new(5.0, 0.0), radius: 5.0, start: PI, sweep: PI };
|
||||
let high = Seg2::Arc { center: Vec2::new(5.0, 8.0), radius: 5.0, start: PI, sweep: -PI };
|
||||
let left = Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(0.0, 8.0) };
|
||||
let right = Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 8.0) };
|
||||
let pl = Plane::XY.offset(3.0);
|
||||
let body = boundary_surface(
|
||||
&[sketch("low", pl, vec![low]), sketch("high", pl, vec![high])],
|
||||
&[sketch("left", pl, vec![left]), sketch("right", pl, vec![right])],
|
||||
)
|
||||
.unwrap();
|
||||
let s = face_surface(&body);
|
||||
for i in 0..=8 {
|
||||
for j in 0..=8 {
|
||||
assert!((s.subs(i as f64 / 8.0, j as f64 / 8.0).z - 3.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stadium: two half-circle arcs (radius 5) bulging out of the 10 × 8 rectangle between two
|
||||
/// lines. Each arc meets its lines at 180°, so the patch is singular at its corners, and the
|
||||
/// reference mesher's walk round the boundary used to run from such a corner onto the
|
||||
/// surface's extrapolation past its domain (a planar spline passes through every point of its
|
||||
/// plane there): the face measured 289 mm² for its 158.5. The vendored mesher's bounded
|
||||
/// projection (`nearest_projection`, vendor/README.md) keeps the walk on the domain. Its area
|
||||
/// is the stadium's, 80 + 25π, by the faces, the measuring mesh and the display mesh.
|
||||
#[test]
|
||||
fn a_patch_with_straight_corners_measures_its_own_area() {
|
||||
use std::f64::consts::PI;
|
||||
let low = Seg2::Arc { center: Vec2::new(5.0, 0.0), radius: 5.0, start: PI, sweep: PI };
|
||||
let high = Seg2::Arc { center: Vec2::new(5.0, 8.0), radius: 5.0, start: PI, sweep: -PI };
|
||||
let left = Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(0.0, 8.0) };
|
||||
let right = Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 8.0) };
|
||||
let pl = Plane::XY.offset(3.0);
|
||||
let body = boundary_surface(
|
||||
&[sketch("low", pl, vec![low]), sketch("high", pl, vec![high])],
|
||||
&[sketch("left", pl, vec![left]), sketch("right", pl, vec![right])],
|
||||
)
|
||||
.unwrap();
|
||||
let want = 80.0 + 25.0 * PI;
|
||||
// Chords of the arcs at 0.01 mm sag cut off up to (2/3)·sag·length ≈ 0.21 mm².
|
||||
let near = |a: f64| (a - want).abs() < 0.25;
|
||||
let faces = body.faces(0.01).unwrap();
|
||||
assert!(faces.len() == 1 && near(faces[0].area), "faces: {faces:?}");
|
||||
let tess = body.tessellate(0.01).unwrap().measure().area;
|
||||
assert!(near(tess), "measuring mesh: {tess}");
|
||||
let display = body.display_mesh(0.01).unwrap().measure().area;
|
||||
assert!(near(display), "display mesh: {display}");
|
||||
}
|
||||
|
||||
/// Three sides: a quarter disc of radius 10 (two radii and the arc) is one face with three
|
||||
/// edges, the collapsed side left out of its loop, and measures 25π by its faces and both
|
||||
/// meshes.
|
||||
#[test]
|
||||
fn a_three_sided_patch_is_one_face_with_three_edges() {
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
let r = 10.0;
|
||||
let arc = Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: r, start: 0.0, sweep: FRAC_PI_2 };
|
||||
let x = Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(r, 0.0) };
|
||||
let y = Seg2::Line { a: Vec2::new(0.0, r), b: Vec2::new(0.0, 0.0) };
|
||||
let pl = Plane::XY;
|
||||
let apex = BoundaryCurve { label: "centre".into(), source: CurveSource::Point(Vec3::ZERO), continuity: Continuity::G0 };
|
||||
let body = boundary_surface(&[sketch("arc", pl, vec![arc]), apex], &[sketch("x", pl, vec![x]), sketch("y", pl, vec![y])]).unwrap();
|
||||
assert!(body.is_surface() && body.face_count() == 1);
|
||||
let edges: std::collections::HashSet<_> = body.solid.edge_iter().map(|e| e.id()).collect();
|
||||
assert_eq!(edges.len(), 3, "the collapsed side is no edge");
|
||||
let want = PI * r * r / 4.0;
|
||||
let near = |a: f64| (a - want).abs() < 0.2;
|
||||
let faces = body.faces(0.01).unwrap();
|
||||
assert!(near(faces[0].area), "faces: {faces:?}");
|
||||
let tess = body.tessellate(0.01).unwrap().measure().area;
|
||||
assert!(near(tess), "measuring mesh: {tess}");
|
||||
let display = body.display_mesh(0.01).unwrap().measure().area;
|
||||
assert!(near(display), "display mesh: {display}");
|
||||
// Two collapsed sides are no patch.
|
||||
let p = |l: &str, q: Vec3| BoundaryCurve { label: l.into(), source: CurveSource::Point(q), continuity: Continuity::G0 };
|
||||
assert!(
|
||||
boundary_surface(&[sketch("arc", pl, vec![arc]), p("o", Vec3::ZERO)], &[p("a", Vec3::new(r, 0.0, 0.0)), sketch("y", pl, vec![y])])
|
||||
.is_err()
|
||||
);
|
||||
assert!(boundary_surface(&[sketch("arc", pl, vec![arc]), p("nan", Vec3::new(f64::NAN, 0.0, 0.0))], &[]).is_err());
|
||||
}
|
||||
|
||||
/// The angle between the two faces' normals across the edge at `p`.
|
||||
fn normal_angle(a: &mt::Surface, b: &mt::Surface, p: Vec3) -> f64 {
|
||||
let n = |s: &mt::Surface| {
|
||||
let (u, v) = s.search_nearest_parameter(p3(p), None, 100).unwrap();
|
||||
let n = s.normal(u, v);
|
||||
Vec3::new(n.x, n.y, n.z)
|
||||
};
|
||||
n(a).dot(n(b)).abs().clamp(0.0, 1.0).acos().to_degrees()
|
||||
}
|
||||
|
||||
/// G1 against a flat sheet's open edge: a loft from the edge to a line above, and a patch
|
||||
/// whose sides are arcs leaving the sheet tangent to it. The normals across the shared edge
|
||||
/// agree within 0.1°; the patch joins the sheet into one surface body.
|
||||
#[test]
|
||||
fn g1_meets_the_sheet_tangent() {
|
||||
let base = sheet();
|
||||
let edge =
|
||||
|g| BoundaryCurve { label: "edge".into(), source: CurveSource::Edges { body: &base, at: vec![Vec3::new(5.0, 0.0, 0.0)] }, continuity: g };
|
||||
let far = || line3("far", Vec3::new(0.0, 10.0, 5.0), Vec3::new(10.0, 10.0, 5.0));
|
||||
let loft = boundary_surface(&[edge(Continuity::G1), far()], &[]).unwrap();
|
||||
let plain = boundary_surface(&[edge(Continuity::G0), far()], &[]).unwrap();
|
||||
let (sb, sl, sp) = (face_surface(&base), face_surface(&loft), face_surface(&plain));
|
||||
for k in 0..=10 {
|
||||
let p = Vec3::new(k as f64, 0.0, 0.0);
|
||||
let a = normal_angle(&sb, &sl, p);
|
||||
assert!(a < 0.1, "G1 at x = {k}: {a}°");
|
||||
}
|
||||
assert!(normal_angle(&sb, &sp, Vec3::new(5.0, 0.0, 0.0)) > 10.0, "G0 has a crease");
|
||||
// Arcs in x = 0 and x = 10 from the edge, tangent to the sheet, up to the far line.
|
||||
let arc_plane = |x: f64| Plane::new(Vec3::new(x, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
|
||||
let arc = Seg2::Arc { center: Vec2::new(0.0, 12.5), radius: 12.5, start: -std::f64::consts::FRAC_PI_2, sweep: 0.927_295_218_001_612_2 };
|
||||
let patch = boundary_surface(
|
||||
&[edge(Continuity::G1), far()],
|
||||
&[sketch("left", arc_plane(0.0), vec![arc]), sketch("right", arc_plane(10.0), vec![arc])],
|
||||
)
|
||||
.unwrap();
|
||||
let spa = face_surface(&patch);
|
||||
for k in 0..=10 {
|
||||
let a = normal_angle(&sb, &spa, Vec3::new(k as f64, 0.0, 0.0));
|
||||
assert!(a < 0.1, "G1 patch at x = {k}: {a}°");
|
||||
}
|
||||
let joined = boundary_join(&base, &patch).unwrap();
|
||||
assert_eq!(joined.face_count(), 2);
|
||||
assert!(
|
||||
boundary_join(&base, &boundary_surface(&[far(), line3("other", Vec3::new(0.0, 20.0, 5.0), Vec3::new(10.0, 20.0, 5.0))], &[]).unwrap())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Normal curvature of `s` at the point nearest `p` in the direction `w` (projected into the
|
||||
/// tangent plane), signed against the unit normal `n`.
|
||||
fn normal_curvature(s: &mt::Surface, p: Vec3, w: Vec3, n: Vec3) -> f64 {
|
||||
let (u, v) = s.search_nearest_parameter(p3(p), None, 100).unwrap();
|
||||
let v3 = |x: mt::Vector3| Vec3::new(x.x, x.y, x.z);
|
||||
let (su, sv) = (v3(s.uder(u, v)), v3(s.vder(u, v)));
|
||||
let (suu, suv, svv) = (v3(s.uuder(u, v)), v3(s.uvder(u, v)), v3(s.vvder(u, v)));
|
||||
let (e, f, g) = (su.dot(su), su.dot(sv), sv.dot(sv));
|
||||
let (l, m, nn) = (suu.dot(n), suv.dot(n), svv.dot(n));
|
||||
let (p1, q1) = (w.dot(su), w.dot(sv));
|
||||
let det = e * g - f * f;
|
||||
let (a, b) = ((p1 * g - q1 * f) / det, (q1 * e - p1 * f) / det);
|
||||
(l * a * a + 2.0 * m * a * b + nn * b * b) / (e * a * a + 2.0 * f * a * b + g * b * b)
|
||||
}
|
||||
|
||||
/// G2 against a real cylinder's open edge (R = 10, the face below the edge): across the edge
|
||||
/// the surface's normal curvature is the cylinder's, −1/R about its outward normal, within
|
||||
/// 0.1 % — also when the far curve is sheared along the edge, so that the surface's own
|
||||
/// cross direction leans. With G1 alone it is not.
|
||||
#[test]
|
||||
fn g2_matches_a_cylinder_across_its_edge() {
|
||||
let r = 10.0;
|
||||
let pl = Plane::new(Vec3::ZERO, Vec3::Y, Vec3::Z).unwrap();
|
||||
let arc = Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: r, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::FRAC_PI_2 };
|
||||
let cyl = crate::extrude_sheet(&pl, &[vec![arc]], 0.0, 10.0).unwrap();
|
||||
let sc = face_surface(&cyl);
|
||||
let made = |g, shear: f64| {
|
||||
let edge =
|
||||
BoundaryCurve { label: "rim".into(), source: CurveSource::Edges { body: &cyl, at: vec![Vec3::new(5.0, r, 0.0)] }, continuity: g };
|
||||
let top = line3("top", Vec3::new(shear, 3.0, 12.0), Vec3::new(10.0 + shear, 3.0, 12.0));
|
||||
face_surface(&boundary_surface(&[edge, top], &[]).unwrap())
|
||||
};
|
||||
for shear in [0.0, 5.0] {
|
||||
let sm = made(Continuity::G2, shear);
|
||||
for k in 1..=9 {
|
||||
let p = Vec3::new(k as f64, r, 0.0);
|
||||
let kc = normal_curvature(&sc, p, Vec3::Z, Vec3::Y);
|
||||
assert!((kc + 1.0 / r).abs() < 1e-9, "the cylinder's own curvature: {kc}");
|
||||
let km = normal_curvature(&sm, p, Vec3::Z, Vec3::Y);
|
||||
assert!((km - kc).abs() < 1e-3 / r, "shear {shear}, x = {k}: {km} across the edge where the cylinder has {kc}");
|
||||
// Along the edge both are straight.
|
||||
assert!(normal_curvature(&sm, p, Vec3::X, Vec3::Y).abs() < 1e-6);
|
||||
assert!(normal_angle(&sc, &sm, p) < 0.1);
|
||||
}
|
||||
}
|
||||
let g1 = made(Continuity::G1, 0.0);
|
||||
let k1 = normal_curvature(&g1, Vec3::new(5.0, r, 0.0), Vec3::Z, Vec3::Y);
|
||||
assert!((k1 + 1.0 / r).abs() > 0.1 / r, "G1 alone does not carry the curvature: {k1}");
|
||||
}
|
||||
|
||||
/// G3 is more than a network holds: refused, never a panic.
|
||||
#[test]
|
||||
fn g3_is_not_supported() {
|
||||
let base = sheet();
|
||||
let edge = BoundaryCurve {
|
||||
label: "edge".into(),
|
||||
source: CurveSource::Edges { body: &base, at: vec![Vec3::new(5.0, 0.0, 0.0)] },
|
||||
continuity: Continuity::G3,
|
||||
};
|
||||
let e = boundary_surface(&[edge, line3("far", Vec3::new(0.0, 10.0, 5.0), Vec3::new(10.0, 10.0, 5.0))], &[]).unwrap_err();
|
||||
assert!(e.to_string().contains("not supported yet"), "{e}");
|
||||
}
|
||||
|
||||
/// Refusals: G1 on a sketch curve, on an edge between two faces, curves that miss.
|
||||
#[test]
|
||||
fn bad_boundaries_are_refused() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
let edge = BoundaryCurve {
|
||||
label: "edge".into(),
|
||||
source: CurveSource::Edges { body: &cube, at: vec![Vec3::new(5.0, 0.0, 0.0)] },
|
||||
continuity: Continuity::G1,
|
||||
};
|
||||
let far = line3("far", Vec3::new(0.0, 10.0, 15.0), Vec3::new(10.0, 10.0, 15.0));
|
||||
let e = boundary_surface(&[edge, far], &[]).unwrap_err();
|
||||
assert!(e.to_string().contains("open edge"), "{e}");
|
||||
let mut tangent = line3("t", Vec3::ZERO, Vec3::X);
|
||||
tangent.continuity = Continuity::G1;
|
||||
assert!(boundary_surface(&[tangent, line3("b", Vec3::Y, Vec3::new(1.0, 1.0, 0.0))], &[]).is_err());
|
||||
let miss = boundary_surface(
|
||||
&[line3("a", Vec3::ZERO, Vec3::X * 10.0), line3("b", Vec3::Y * 10.0, Vec3::new(10.0, 10.0, 0.0))],
|
||||
&[line3("c", Vec3::ZERO, Vec3::Y * 10.0), line3("d", Vec3::new(10.0, 0.0, 2.0), Vec3::new(10.0, 10.0, 2.0))],
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(miss.to_string().contains("don't meet"), "{miss}");
|
||||
let pts = BoundaryCurve { label: "p".into(), source: CurveSource::Points(vec![Vec3::ZERO; 3]), continuity: Continuity::G0 };
|
||||
assert!(boundary_surface(&[pts, line3("b", Vec3::Y, Vec3::new(1.0, 1.0, 0.0))], &[]).is_err());
|
||||
let nan = BoundaryCurve {
|
||||
label: "n".into(),
|
||||
source: CurveSource::Points(vec![Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]),
|
||||
continuity: Continuity::G0,
|
||||
};
|
||||
assert!(boundary_surface(&[nan, line3("b", Vec3::Y, Vec3::new(1.0, 1.0, 0.0))], &[]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,19 @@ pub(crate) fn frame_ok(plane: &Plane) -> bool {
|
||||
|
||||
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
|
||||
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
|
||||
extrude_with(plane, regions, start, end, false)
|
||||
}
|
||||
|
||||
/// [`extrude`] with each run of profile lines that continue one another in a straight line (a side
|
||||
/// drawn in two pieces, a mirrored half meeting its mirror image) swept into one side face over
|
||||
/// all its pieces, rather than coplanar faces with an edge between them (#1788). The side's
|
||||
/// history lists every piece under it. For the Extrude feature; other builders (sheet metal's
|
||||
/// flat pattern, tools) keep a face per segment.
|
||||
pub fn extrude_whole_sides(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
|
||||
extrude_with(plane, regions, start, end, true)
|
||||
}
|
||||
|
||||
fn extrude_with(plane: &Plane, regions: &[Region2], start: f64, end: f64, whole_sides: bool) -> Result<Vec<Body>> {
|
||||
if !frame_ok(plane) {
|
||||
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
|
||||
}
|
||||
@@ -206,7 +219,19 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
for r in regions {
|
||||
region_ok(r)?;
|
||||
let base = plane.offset(lo);
|
||||
let profile = region_wires(&base, &oriented(r)?)?;
|
||||
let pieces = oriented(r)?;
|
||||
let numbered = if whole_sides { pieces.map_loops(|l| l.merged_lines(continues_straight)) } else { pieces.clone() };
|
||||
// A joined side's source is its first piece, a segment of the profile as drawn (the side
|
||||
// lies along it): the sketch curve it is certified and named by, as when it stood alone.
|
||||
let first_piece = |side: &ProfileEdge| {
|
||||
pieces
|
||||
.loops()
|
||||
.flat_map(|l| l.lp.segs.iter())
|
||||
.copied()
|
||||
.find(|piece| side.numbers.len() > 1 && piece.start() == side.segment.start())
|
||||
.unwrap_or(side.segment)
|
||||
};
|
||||
let profile = region_wires(&base, &numbered)?;
|
||||
let wires = wires_of(&profile);
|
||||
let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?;
|
||||
let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?;
|
||||
@@ -263,7 +288,7 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
_ => None,
|
||||
};
|
||||
(
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: side.segment, plane: basis, coalesced: false }),
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: first_piece(side), plane: basis, coalesced: false }),
|
||||
Some(record::profile(&side.numbers)),
|
||||
)
|
||||
} else if Some(*id) == end {
|
||||
@@ -285,6 +310,14 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `t` carries on from `s` in the same straight line: the turn at their meeting point is within
|
||||
/// rounding of none, measured against both lengths.
|
||||
fn continues_straight(s: &Seg2, t: &Seg2) -> bool {
|
||||
let (u, v) = (s.end() - s.start(), t.end() - t.start());
|
||||
let (lu, lv) = (u.len(), v.len());
|
||||
lu > 0.0 && lv > 0.0 && s.end().dist(t.start()) <= 1e-9 * (lu + lv) && u.dot(v) > 0.0 && u.cross(v).abs() <= 1e-9 * lu * lv
|
||||
}
|
||||
|
||||
/// Choose the per-profile axis sign whose positive rotation follows the sketch normal.
|
||||
pub fn canonical_revolve_axis(plane: &Plane, region: &Region2, axis_origin: Vec2, axis_dir: Vec2) -> Result<Vec3> {
|
||||
if !axis_origin.is_finite() || !axis_dir.is_finite() || !plane.origin.is_finite() || !plane.x.is_finite() || !plane.y.is_finite() {
|
||||
|
||||
@@ -244,3 +244,36 @@ fn face_sources_follow_kept_split_and_agreeing_merged_faces() {
|
||||
let s = h.face_sources(&[&a]);
|
||||
assert_eq!(s, vec![src(0), src(1), None, src(2), None]);
|
||||
}
|
||||
|
||||
/// A side drawn in two collinear pieces (here the bottom edge, split at x = 10) sweeps into one
|
||||
/// side face over both pieces, not two coplanar faces with an edge between them (#1788).
|
||||
#[test]
|
||||
fn collinear_profile_lines_extrude_into_one_side_face() {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let pts = [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)].map(|(x, y)| Vec2::new(x, y));
|
||||
let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] };
|
||||
// Plain `extrude` keeps a face per segment (sheet metal's flat pattern relies on it).
|
||||
assert_eq!(crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 0.0, 5.0).unwrap()[0].face_count(), 7);
|
||||
let body = crate::extrude_whole_sides(&Plane::XY, &[region], 0.0, 5.0).unwrap().pop().unwrap();
|
||||
assert_eq!(body.face_count(), 6, "a box has six faces");
|
||||
assert!((crate::measure(&body).unwrap().volume - 1000.0).abs() < 1e-9);
|
||||
let h = body.history().unwrap();
|
||||
h.check(&body, &[]).unwrap();
|
||||
let mut sides: Vec<Vec<FaceOrigin>> =
|
||||
h.faces.iter().map(|f| f.origins.clone()).filter(|o| matches!(o.first(), Some(FaceOrigin::Profile { .. }))).collect();
|
||||
sides.sort();
|
||||
assert_eq!(
|
||||
sides,
|
||||
vec![
|
||||
vec![FaceOrigin::Profile { segment: 0 }, FaceOrigin::Profile { segment: 1 }],
|
||||
vec![FaceOrigin::Profile { segment: 2 }],
|
||||
vec![FaceOrigin::Profile { segment: 3 }],
|
||||
vec![FaceOrigin::Profile { segment: 4 }],
|
||||
],
|
||||
"the bottom side is generated by both its pieces"
|
||||
);
|
||||
// A corner is not a straight run: an L-shaped step keeps its faces.
|
||||
let step = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (20.0, 5.0), (20.0, 10.0), (0.0, 10.0)].map(|(x, y)| Vec2::new(x, y));
|
||||
let body = crate::extrude_whole_sides(&Plane::XY, &[Region2 { outer: Loop2::polygon(&step), holes: vec![] }], 0.0, 5.0).unwrap().pop().unwrap();
|
||||
assert_eq!(body.face_count(), 8);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ mod boolean_guard;
|
||||
mod boolean_record;
|
||||
mod bore_guard;
|
||||
mod boundary;
|
||||
mod boundary_network;
|
||||
mod bridge;
|
||||
mod build;
|
||||
mod clearance;
|
||||
@@ -121,6 +122,8 @@ mod surface_extend;
|
||||
mod surface_kind;
|
||||
mod surface_offset;
|
||||
mod surface_ruled;
|
||||
#[cfg(test)]
|
||||
mod surface_second_derivative_tests;
|
||||
mod surface_untrim;
|
||||
mod surfaces;
|
||||
mod sweep_path;
|
||||
@@ -145,10 +148,11 @@ pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fill
|
||||
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
|
||||
pub use boundary::{Boundary, boundary_patch};
|
||||
pub use boundary_network::{BoundaryCurve, CurveSource, MAX_CHAIN_CURVES, MAX_EDGE_PICKS, boundary_join, boundary_surface};
|
||||
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
|
||||
pub use build::{
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve,
|
||||
revolve_whole, sphere, sweep, sweep_with, torus,
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, extrude_whole_sides, loft,
|
||||
path_polyline, revolve, revolve_whole, sphere, sweep, sweep_with, torus,
|
||||
};
|
||||
pub use clearance::{Clearance, Occupancy};
|
||||
pub use coaxial_overlap::{CoaxialOverlap, coaxial_overlap};
|
||||
|
||||
@@ -343,6 +343,10 @@ fn faceted_polys(b: &Body, tol: f64, operand: usize) -> Option<Vec<Poly>> {
|
||||
/// triangles of `b`; an edge between facets of one face of `a`, or between triangles of `b`,
|
||||
/// has no origin.
|
||||
pub fn faceted_boolean(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Option<Body>> {
|
||||
crate::profile::timed(crate::profile::Kind::Boolean, || faceted_boolean_untimed(a, b, op, tol))
|
||||
}
|
||||
|
||||
fn faceted_boolean_untimed(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Option<Body>> {
|
||||
a.require_brep("a boolean")?;
|
||||
if !(tol.is_finite() && tol > 0.0) {
|
||||
return Err(KernelError::Invalid("facet tolerance".into()));
|
||||
|
||||
@@ -435,6 +435,9 @@ pub(crate) struct Index {
|
||||
points: Vec<Vec3>,
|
||||
/// Per edge (edge order): whether it is a line.
|
||||
lines: Vec<bool>,
|
||||
/// Per edge (edge order) that is not a line: the points its curve lies within (see
|
||||
/// [`control_hull`]); `None` where they are not known.
|
||||
hulls: Vec<Option<Vec<Vec3>>>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
@@ -451,6 +454,7 @@ impl Index {
|
||||
}
|
||||
}
|
||||
let lines = edges.iter().map(|e| matches!(e.curve(), mt::Curve::Line(_))).collect();
|
||||
let hulls = edges.iter().map(|e| control_hull(&e.curve())).collect();
|
||||
let mut between: std::collections::HashMap<(usize, usize), Vec<usize>> = std::collections::HashMap::new();
|
||||
for (i, fs) in bounds.iter().enumerate() {
|
||||
match fs.as_slice() {
|
||||
@@ -464,7 +468,7 @@ impl Index {
|
||||
.zip(bounds)
|
||||
.map(|(e, fs)| (fs, vertices.get(&e.absolute_front().id()).copied(), vertices.get(&e.absolute_back().id()).copied()))
|
||||
.collect();
|
||||
Index { operand, edges: crate::record::edge_index(&b.solid), bounds, between, vertices, points, lines }
|
||||
Index { operand, edges: crate::record::edge_index(&b.solid), bounds, between, vertices, points, lines, hulls }
|
||||
}
|
||||
|
||||
/// The tag of the segments read off edge `e` (as it runs in a wire) of cap face `cap`.
|
||||
@@ -517,7 +521,7 @@ struct Stations {
|
||||
/// Each wall's highest level.
|
||||
tops: std::collections::HashMap<usize, f64>,
|
||||
/// Per wall, the edges across it: the other wall and the edge's lowest and highest levels.
|
||||
/// A curve other than a line spans every level.
|
||||
/// A curve other than a line spans every level, unless it lies at one level.
|
||||
cross: std::collections::HashMap<usize, Vec<(usize, f64, f64)>>,
|
||||
}
|
||||
|
||||
@@ -540,7 +544,13 @@ impl Stations {
|
||||
let (h, k) = (plane.height(*p), plane.height(*q));
|
||||
(h.min(k), h.max(k))
|
||||
}
|
||||
_ => (f64::NEG_INFINITY, f64::INFINITY),
|
||||
// A curve at one level (where a piece of a wall's surface stacked on another
|
||||
// meets it: a boss's cylinder over the arc of the plate it stands on) crosses
|
||||
// the walls there only. Any other spans every level.
|
||||
_ => match index.hulls.get(i).and_then(Option::as_deref).map(|hull| level_span(hull, plane)) {
|
||||
Some((lo, hi)) if hi - lo < tol => (lo, hi),
|
||||
_ => (f64::NEG_INFINITY, f64::INFINITY),
|
||||
},
|
||||
};
|
||||
cross.entry(f).or_default().push((g, lo, hi));
|
||||
cross.entry(g).or_default().push((f, lo, hi));
|
||||
@@ -590,6 +600,35 @@ impl Stations {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most control points [`control_hull`] reads of one curve.
|
||||
const MAX_HULL_POINTS: usize = 4096;
|
||||
|
||||
/// The points curve `c` lies within: a B-spline's control points, or a NURBS curve's where its
|
||||
/// weights are positive (the curve then lies in their convex hull). `None` for a line, an
|
||||
/// intersection curve, other weights or too many points.
|
||||
fn control_hull(c: &mt::Curve) -> Option<Vec<Vec3>> {
|
||||
match c {
|
||||
mt::Curve::BSplineCurve(c) if c.control_points().len() <= MAX_HULL_POINTS => Some(c.control_points().iter().map(|p| from_p3(*p)).collect()),
|
||||
mt::Curve::NurbsCurve(c) if c.control_points().len() <= MAX_HULL_POINTS => {
|
||||
c.control_points().iter().map(|p| (p.w > 0.0 && p.w.is_finite()).then(|| Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w))).collect()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest and highest levels of `points` (infinite where there are none, or one isn't
|
||||
/// finite).
|
||||
fn level_span(points: &[Vec3], plane: &Plane) -> (f64, f64) {
|
||||
let span = points
|
||||
.iter()
|
||||
.map(|p| plane.height(*p))
|
||||
.try_fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), h| h.is_finite().then(|| (lo.min(h), hi.max(h))));
|
||||
match span {
|
||||
Some((lo, hi)) if lo <= hi => (lo, hi),
|
||||
_ => (f64::NEG_INFINITY, f64::INFINITY),
|
||||
}
|
||||
}
|
||||
|
||||
/// The highest level of face `f`'s vertices.
|
||||
fn top_of(f: &mt::Face, plane: &Plane) -> f64 {
|
||||
f.vertex_iter().map(|v| plane.height(from_p3(v.point()))).fold(f64::NEG_INFINITY, f64::max)
|
||||
|
||||
@@ -133,4 +133,18 @@ mod tests {
|
||||
let st = STATE.with(Cell::get);
|
||||
assert!(!st.on && !st.busy);
|
||||
}
|
||||
#[test]
|
||||
fn a_faceted_boolean_is_boolean_time_and_a_reference_mesh_tessellation() {
|
||||
use crate::{BoolOp, box_solid, faceted_boolean};
|
||||
use solidcraft_geom::Vec3;
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
let tool = box_solid(Vec3::new(5.0, 5.0, 5.0), Vec3::new(15.0, 15.0, 15.0)).unwrap().tessellate(0.1).unwrap();
|
||||
// The thread groove's route: the faceting it does first is the boolean's time too.
|
||||
let (r, t) = capture(|| faceted_boolean(&a, &tool, BoolOp::Cut, 0.01));
|
||||
assert!(r.unwrap().is_some());
|
||||
assert!(t.boolean_ms > 0.0 && t.tessellate_ms == 0.0, "{t:?}");
|
||||
let (m, t) = capture(|| a.reference_display_mesh_angular(0.01, crate::DISPLAY_ANGLE));
|
||||
assert!(!m.unwrap().triangles.is_empty());
|
||||
assert!(t.tessellate_ms > 0.0 && t.boolean_ms == 0.0, "{t:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! The vendored truck-geometry's second derivatives of B-spline and NURBS surfaces (`uuder`,
|
||||
//! `vvder`) against finite differences of the surface itself, for degrees 2 to 5. Upstream's
|
||||
//! were short of the factor p − 1, so every curvature read from a cubic surface came out half its
|
||||
//! value (#763: a G2 boundary surface measured half the cylinder's curvature it does carry).
|
||||
//! These pin the vendored fix, so a refresh of the crate can't quietly bring the error back.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
use truck_modeling::ParametricSurface;
|
||||
|
||||
use crate::body::from_p3;
|
||||
|
||||
/// A degree-`p` × degree-`q` surface on a 5 × 6 net with an interior knot each way, bent in z.
|
||||
fn surface(p: usize, q: usize) -> mt::BSplineSurface<mt::Point3> {
|
||||
let knots = |d: usize, n: usize| {
|
||||
let inner: Vec<f64> = (1..n - d).map(|k| k as f64 / (n - d) as f64).collect();
|
||||
let mut k = vec![0.0; d + 1];
|
||||
k.extend(inner);
|
||||
k.extend(vec![1.0; d + 1]);
|
||||
mt::KnotVec::from(k)
|
||||
};
|
||||
let (nu, nv) = (p + 3, q + 3);
|
||||
let rows: Vec<Vec<mt::Point3>> = (0..nu)
|
||||
.map(|i| {
|
||||
(0..nv)
|
||||
.map(|j| {
|
||||
let (x, y) = (i as f64 * 2.0, j as f64 * 1.5);
|
||||
mt::Point3::new(x + 0.3 * y, y, 0.4 * x * x - 0.2 * y * y + 0.1 * x * y)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
mt::BSplineSurface::new((knots(p, nu), knots(q, nv)), rows)
|
||||
}
|
||||
|
||||
/// Second differences of `f` along the parameter line, central inside and one-sided at the ends.
|
||||
fn second(f: impl Fn(f64) -> Vec3, t: f64) -> Vec3 {
|
||||
let h = 1e-4;
|
||||
if t < 2.0 * h {
|
||||
(f(t) * 2.0 - f(t + h) * 5.0 + f(t + 2.0 * h) * 4.0 - f(t + 3.0 * h)) / (h * h)
|
||||
} else if t > 1.0 - 2.0 * h {
|
||||
(f(t) * 2.0 - f(t - h) * 5.0 + f(t - 2.0 * h) * 4.0 - f(t - 3.0 * h)) / (h * h)
|
||||
} else {
|
||||
(f(t - h) - f(t) * 2.0 + f(t + h)) / (h * h)
|
||||
}
|
||||
}
|
||||
|
||||
fn v3(x: mt::Vector3) -> Vec3 {
|
||||
Vec3::new(x.x, x.y, x.z)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bspline_surface_second_derivatives_match_finite_differences() {
|
||||
for p in 2..=5 {
|
||||
for q in 2..=5 {
|
||||
let s = surface(p, q);
|
||||
for (u, v) in [(0.0, 0.0), (0.37, 0.0), (0.5, 0.61), (1.0, 0.2), (0.13, 1.0)] {
|
||||
let uu = second(|t| from_p3(s.subs(t, v)), u);
|
||||
let vv = second(|t| from_p3(s.subs(u, t)), v);
|
||||
let scale = uu.len().max(vv.len()).max(1.0);
|
||||
assert!(v3(s.uuder(u, v)).dist(uu) < 1e-4 * scale, "degree ({p}, {q}) uuder at ({u}, {v}): {:?} vs {uu:?}", s.uuder(u, v));
|
||||
assert!(v3(s.vvder(u, v)).dist(vv) < 1e-4 * scale, "degree ({p}, {q}) vvder at ({u}, {v}): {:?} vs {vv:?}", s.vvder(u, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A rational surface's second derivatives come from its homogeneous B-spline's: they are right
|
||||
/// too.
|
||||
#[test]
|
||||
fn nurbs_surface_second_derivatives_match_finite_differences() {
|
||||
let b = surface(3, 3);
|
||||
let (ku, kv) = b.knot_vecs().clone();
|
||||
let rows: Vec<Vec<mt::Vector4>> = b
|
||||
.control_points()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, row)| {
|
||||
row.iter()
|
||||
.enumerate()
|
||||
.map(|(j, p)| {
|
||||
let w = 1.0 + 0.3 * ((i + 2 * j) % 3) as f64;
|
||||
mt::Vector4::new(p.x * w, p.y * w, p.z * w, w)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let s = mt::NurbsSurface::new(mt::BSplineSurface::new((ku, kv), rows));
|
||||
for (u, v) in [(0.0, 0.0), (0.37, 0.0), (0.5, 0.61), (1.0, 0.2)] {
|
||||
let uu = second(|t| from_p3(s.subs(t, v)), u);
|
||||
let vv = second(|t| from_p3(s.subs(u, t)), v);
|
||||
let scale = uu.len().max(vv.len()).max(1.0);
|
||||
assert!(v3(s.uuder(u, v)).dist(uu) < 1e-4 * scale, "uuder at ({u}, {v}): {:?} vs {uu:?}", s.uuder(u, v));
|
||||
assert!(v3(s.vvder(u, v)).dist(vv) < 1e-4 * scale, "vvder at ({u}, {v}): {:?} vs {vv:?}", s.vvder(u, v));
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ fn surface_of(shell: mt::Shell) -> Body {
|
||||
|
||||
/// A body from faces: a solid when they close, else a surface; with the history of the
|
||||
/// operation that made the faces from `operands` as `rec` records.
|
||||
fn body_of(faces: Vec<mt::Face>, operands: &[&mt::Solid], rec: &Rebuild) -> Result<Body> {
|
||||
pub(crate) fn body_of(faces: Vec<mt::Face>, operands: &[&mt::Solid], rec: &Rebuild) -> Result<Body> {
|
||||
let shell = mt::Shell::from(faces);
|
||||
if shell.shell_condition() == mt::ShellCondition::Closed {
|
||||
crate::record::rebuilt(mt::Solid::new_unchecked(vec![shell]), operands, rec)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! A solve with geometry held moves the rest as little as it can.
|
||||
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use crate::{Analysis, ConstraintKind, Hold, Sketch, solve_with};
|
||||
|
||||
fn v(x: f64, y: f64) -> Vec2 {
|
||||
Vec2::new(x, y)
|
||||
}
|
||||
|
||||
/// A rectangle with a divider whose ends are held on the bottom and top sides (point on curve).
|
||||
/// Dragging the top-left corner tilts the top side: the divider's top end slides along with it,
|
||||
/// the far corner stays.
|
||||
#[test]
|
||||
fn a_point_held_on_a_line_slides_instead_of_swinging_the_line() {
|
||||
let mut sk = Sketch::new();
|
||||
let c: Vec<usize> = [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
|
||||
let sides: Vec<usize> = (0..4).map(|i| sk.add_line_pts(c[i], c[(i + 1) % 4], None).unwrap()).collect();
|
||||
let bottom = sk.add_point(v(20.0, 0.0), None).unwrap();
|
||||
let top = sk.add_point(v(20.0, 30.0), None).unwrap();
|
||||
sk.add_line_pts(bottom, top, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p: bottom, c: sides[0] }, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p: top, c: sides[2] }, None).unwrap();
|
||||
let before: Vec<Vec2> = sk.points.iter().map(|p| p.pos).collect();
|
||||
sk.points[c[3]].pos = v(1.0, 31.0);
|
||||
let rep = solve_with(&mut sk, &Hold { points: vec![c[3]], radii: vec![] }, Some(&before), Analysis::StatusOnly, None);
|
||||
assert!(rep.ok(), "{rep:?}");
|
||||
let moved = |i: usize| sk.points[i].pos.dist(before[i]);
|
||||
// The top side through (1, 31) and (40, 30) passes x = 20 at y ≈ 30.51: the divider's end
|
||||
// needs about half a millimetre. The far corner needs nothing.
|
||||
assert!(moved(top) < 0.6, "divider end moved {}", moved(top));
|
||||
assert!(moved(c[2]) < 0.3, "far corner moved {}", moved(c[2]));
|
||||
assert!(moved(c[1]) < 1e-6 && moved(bottom) < 1e-6, "the bottom has no reason to move");
|
||||
}
|
||||
|
||||
/// The same rectangle, dragging the top-right corner down: the top side turns about its other
|
||||
/// corner, which stays, and the divider's end slides down with it.
|
||||
#[test]
|
||||
fn the_far_corner_stays_when_the_side_a_point_is_held_on_turns() {
|
||||
let mut sk = Sketch::new();
|
||||
let c: Vec<usize> = [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
|
||||
let sides: Vec<usize> = (0..4).map(|i| sk.add_line_pts(c[i], c[(i + 1) % 4], None).unwrap()).collect();
|
||||
let bottom = sk.add_point(v(20.0, 0.0), None).unwrap();
|
||||
let top = sk.add_point(v(20.0, 30.0), None).unwrap();
|
||||
sk.add_line_pts(bottom, top, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p: bottom, c: sides[0] }, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p: top, c: sides[2] }, None).unwrap();
|
||||
// Down in steps, as a live drag goes: each solve starts from the last.
|
||||
for k in 1..=10 {
|
||||
let before: Vec<Vec2> = sk.points.iter().map(|p| p.pos).collect();
|
||||
sk.points[c[2]].pos = v(40.0, 30.0 - k as f64);
|
||||
let rep = solve_with(&mut sk, &Hold { points: vec![c[2]], radii: vec![] }, Some(&before), Analysis::StatusOnly, None);
|
||||
assert!(rep.ok(), "{rep:?}");
|
||||
}
|
||||
let corner = sk.points[c[3]].pos;
|
||||
assert!(corner.dist(v(0.0, 30.0)) < 0.1, "the far corner went to {corner:?}");
|
||||
// On the turned side, near where it was.
|
||||
let (end, a, b) = (sk.points[top].pos, corner, sk.points[c[2]].pos);
|
||||
let off_side = ((b - a).cross(end - a) / a.dist(b)).abs();
|
||||
assert!(off_side < 1e-6 && (end.x - 20.0).abs() < 1.0, "the divider's end is at {end:?}");
|
||||
}
|
||||
@@ -80,6 +80,8 @@ mod ends_tests;
|
||||
#[cfg(test)]
|
||||
mod font_tests;
|
||||
#[cfg(test)]
|
||||
mod least_motion_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod thin_tests;
|
||||
|
||||
@@ -123,7 +123,7 @@ pub(super) fn dependent_blocks(
|
||||
/// dimension (it constrains nothing) or an unknown index.
|
||||
pub fn constraint_residual(sk: &Sketch, ci: usize) -> Option<f64> {
|
||||
let c = sk.constraints.get(ci).filter(|c| !c.driven)?;
|
||||
let fixed = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
|
||||
let fixed = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
|
||||
let on_curve = points_on_curves(sk);
|
||||
let b = match super::touch_point(sk, &c.kind, &on_curve) {
|
||||
Some(p) => Block::TangentAt(ci, &c.kind, p),
|
||||
|
||||
+33
-11
@@ -36,6 +36,9 @@ const MAX_COMPONENT_VARS: usize = 4_000;
|
||||
const TOL: f64 = 1e-9;
|
||||
/// A solution is accepted when every residual is below this (mm).
|
||||
const ACCEPT: f64 = 1e-6;
|
||||
/// How much less it costs a solve to move a point held on a curve than any other point: such a
|
||||
/// point is placed relative to the curve, so it follows the curve rather than the curve it.
|
||||
const SLIDE_WEIGHT: f64 = 0.01;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -107,6 +110,9 @@ struct Layout {
|
||||
n: usize,
|
||||
/// The point coordinates' variables: `0..points`.
|
||||
points: usize,
|
||||
/// Per variable: a coordinate of a point held on a curve (point on curve, midpoint), which
|
||||
/// a solve slides along it before moving the curve (see [`SLIDE_WEIGHT`]).
|
||||
slide: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
@@ -145,7 +151,7 @@ pub(crate) fn held_points(sk: &Sketch, hold: &Hold) -> Vec<bool> {
|
||||
|
||||
fn layout(sk: &Sketch, hold: &Hold) -> Layout {
|
||||
let mut n = 0;
|
||||
let pvar = held_points(sk, hold)
|
||||
let pvar: Vec<Option<usize>> = held_points(sk, hold)
|
||||
.iter()
|
||||
.map(|f| {
|
||||
if *f {
|
||||
@@ -169,7 +175,19 @@ fn layout(sk: &Sketch, hold: &Hold) -> Layout {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
Layout { pvar, rvar, n, points }
|
||||
let mut slide = vec![false; n];
|
||||
for c in &sk.constraints {
|
||||
if let ConstraintKind::PointOnCurve { p, .. } | ConstraintKind::Midpoint { p, .. } = c.kind
|
||||
&& let Some(Some(v)) = pvar.get(p)
|
||||
{
|
||||
for k in [*v, *v + 1] {
|
||||
if let Some(s) = slide.get_mut(k) {
|
||||
*s = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Layout { pvar, rvar, n, points, slide }
|
||||
}
|
||||
|
||||
/// Read-only view of the sketch with variable values substituted.
|
||||
@@ -618,7 +636,7 @@ fn measure(s: &State, k: &ConstraintKind) -> Option<f64> {
|
||||
/// What a dimension measures on the sketch as it stands (mm, radians for angles); `None` for a
|
||||
/// constraint that is not a dimension.
|
||||
pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option<f64> {
|
||||
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
|
||||
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
|
||||
measure(&State { sk, lay: &lay, x: &[] }, k).filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
@@ -954,13 +972,17 @@ fn lm_capped(
|
||||
}
|
||||
}
|
||||
let mut improved = false;
|
||||
// Damping floor: variables the residuals barely see must not take huge steps.
|
||||
let floor = (0..n).map(|i| a.get(i, i)).fold(0.0_f64, f64::max) * 1e-4;
|
||||
// Damping by distance moved, not by JᵀJ's diagonal (Marquardt): of the steps that fit the
|
||||
// residuals equally well it takes the shortest, so what the constraints leave free moves
|
||||
// as little as it can. (Scaled by the diagonal, a variable the residuals see weakly is
|
||||
// cheap to move: a point held on a line made the line's far end swing round instead of
|
||||
// the point sliding along it.) A point held on a curve is cheaper still, so it slides
|
||||
// rather than the curve's ends moving.
|
||||
let weight: Vec<f64> = vars.iter().map(|&v| if lay.slide.get(v).copied().unwrap_or(false) { SLIDE_WEIGHT } else { 1.0 }).collect();
|
||||
for _ in 0..30 {
|
||||
let mut m = a.clone();
|
||||
for i in 0..n {
|
||||
let d = a.get(i, i);
|
||||
m.add(i, i, lambda * (d.max(floor) + 1e-9) + 1e-12);
|
||||
for (i, w) in weight.iter().enumerate() {
|
||||
m.add(i, i, lambda * w + 1e-12);
|
||||
}
|
||||
let rhs: Vec<f64> = g.iter().map(|v| -v).collect();
|
||||
let Some(step) = lin_solve(m, rhs) else {
|
||||
@@ -2201,7 +2223,7 @@ fn solve_inner(
|
||||
None => true,
|
||||
};
|
||||
// Driven dimensions measure the solved geometry.
|
||||
let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
|
||||
let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
|
||||
let measured: Vec<Option<f64>> =
|
||||
sk.constraints.iter().map(|c| if c.driven { measure(&State { sk, lay: &fixed_lay, x: &[] }, &c.kind) } else { None }).collect();
|
||||
for (c, m) in sk.constraints.iter_mut().zip(measured) {
|
||||
@@ -2265,7 +2287,7 @@ mod continuation_unit_tests {
|
||||
let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap();
|
||||
let b = sk.add_point(Vec2::new(40.0, 0.0), None).unwrap();
|
||||
let c = sk.add_point(Vec2::new(20.0, 1e-6), None).unwrap();
|
||||
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
|
||||
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
|
||||
let turn = Orient::Turn([a, b, c]);
|
||||
let state =
|
||||
|sk: &Sketch| (orientation(&State { sk, lay: &lay, x: &[] }, turn), reference_orientation(&State { sk, lay: &lay, x: &[] }, turn));
|
||||
@@ -2285,7 +2307,7 @@ mod continuation_unit_tests {
|
||||
let q = sk.add_point(Vec2::new(10.0, 5.0), None).unwrap();
|
||||
let r = sk.add_point(Vec2::new(0.0, 10.0), None).unwrap();
|
||||
let l = sk.add_line(Vec2::new(-5.0, -5.0), Vec2::new(5.0, -5.0), None, None, None).unwrap();
|
||||
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
|
||||
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
|
||||
let sign = |sk: &Sketch, o: Orient| orientation(&State { sk, lay: &lay, x: &[] }, o);
|
||||
let checks = [Orient::SideX(p, q, 10.0), Orient::SideY(p, q, 5.0), Orient::OfLine(q, l, 10.0), Orient::Turn([p, q, r])];
|
||||
let before: Vec<_> = checks.iter().map(|o| sign(&sk, *o)).collect();
|
||||
|
||||
@@ -207,6 +207,8 @@ pub struct AgentChat {
|
||||
pub agent_id: Option<String>,
|
||||
/// The agent the running backend is (for permissions and "always allow").
|
||||
running_id: String,
|
||||
/// The popped-out chat window was given its saved place (it is open); see `panel::window_builder`.
|
||||
pub(crate) window_placed: bool,
|
||||
/// The agent's own modes, and the current one.
|
||||
modes: Vec<(String, String)>,
|
||||
mode: String,
|
||||
@@ -1131,6 +1133,9 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
|
||||
if let Some(m) = p.get("minimized").and_then(Value::as_bool) {
|
||||
app.preferences.agent_chat_minimized = m;
|
||||
}
|
||||
if let Some(w) = p.get("popped").and_then(Value::as_bool) {
|
||||
app.preferences.agent_chat_popped = w;
|
||||
}
|
||||
if let Some(w) = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()) {
|
||||
app.preferences.agent_chat_width = (w as f32).clamp(panel::MIN_WIDTH, panel::MAX_WIDTH);
|
||||
panel::forget_width(ctx);
|
||||
@@ -1233,6 +1238,8 @@ pub fn status(app: &SolveApp) -> Value {
|
||||
"open": c.open,
|
||||
"side": app.preferences.agent_chat_side,
|
||||
"minimized": app.preferences.agent_chat_minimized,
|
||||
"popped": app.preferences.agent_chat_popped,
|
||||
"window": app.preferences.agent_chat_window,
|
||||
"width": app.preferences.agent_chat_width,
|
||||
"state": c.state().id(),
|
||||
"agent": c.agent,
|
||||
|
||||
@@ -58,6 +58,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
|
||||
toggle(app);
|
||||
}
|
||||
// A window that isn't drawn this frame closes; the next one opens at the saved place.
|
||||
if !(app.agent_chat.open && app.preferences.agent_chat_popped) {
|
||||
app.agent_chat.window_placed = false;
|
||||
}
|
||||
// New chat, when the chat has focus (its input, or the pointer over the panel).
|
||||
let in_chat = ctx.memory(|m| m.has_focus(Id::new("sc_agent_chat_input")))
|
||||
|| ctx.pointer_hover_pos().is_some_and(|p| app.agent_chat.panel_rect.is_some_and(|r| r.contains(p)));
|
||||
@@ -72,6 +76,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
|
||||
refresh(app, &ctx);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if app.preferences.agent_chat_popped {
|
||||
window(app, &ctx);
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let left = app.preferences.agent_chat_side == "left";
|
||||
let side = |id: Id| if left { egui::Panel::left(id) } else { egui::Panel::right(id) };
|
||||
@@ -107,7 +116,78 @@ fn request_new_chat(app: &mut SolveApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small drawn button: `kind` is `minimize`, `close`, `stopturn` or `expand`.
|
||||
/// A saved window place (`[x, y, width, height]`) that can be used: finite, a usable size, and
|
||||
/// not absurdly far away. `x` and `y` may be negative: a screen left of or above the main one.
|
||||
pub fn window_place(saved: Option<[f32; 4]>) -> Option<([f32; 2], [f32; 2])> {
|
||||
let [x, y, w, h] = saved?;
|
||||
let usable = [x, y, w, h].iter().all(|v| v.is_finite())
|
||||
&& (MIN_WIDTH..=10_000.0).contains(&w)
|
||||
&& (200.0..=10_000.0).contains(&h)
|
||||
&& x.abs() <= 100_000.0
|
||||
&& y.abs() <= 100_000.0;
|
||||
usable.then_some(([x, y], [w, h]))
|
||||
}
|
||||
|
||||
/// The chat window's settings. The saved place and size go only to a window that is opening:
|
||||
/// egui turns a changed place into a move, and the system rounds what it is given, so giving it
|
||||
/// every frame made the window chase its own rounding (it shook a pixel up and down).
|
||||
pub fn window_builder(app: &SolveApp) -> egui::ViewportBuilder {
|
||||
let title = format!("{} — {}", app.session.doc.name, tr("ui.agent_chat"));
|
||||
let builder = egui::ViewportBuilder::default().with_title(title).with_min_inner_size([MIN_WIDTH, 320.0]);
|
||||
if app.agent_chat.window_placed {
|
||||
return builder;
|
||||
}
|
||||
match window_place(app.preferences.agent_chat_window) {
|
||||
Some((pos, size)) => builder.with_position(pos).with_inner_size(size),
|
||||
None => builder.with_inner_size([app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH), 720.0]),
|
||||
}
|
||||
}
|
||||
|
||||
/// The chat in its own window, named after the design: a native window on the desktop, which can
|
||||
/// go to another screen, or a window floating inside the app where the system can't open one.
|
||||
/// The system's close button closes the chat (it opens there again); Dock puts it back.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn window(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let builder = window_builder(app);
|
||||
app.agent_chat.window_placed = true;
|
||||
// The side panel isn't drawn: its place no longer counts as "in the chat".
|
||||
app.agent_chat.panel_rect = None;
|
||||
ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("sc_agent_chat_window"), builder, |ui, class| {
|
||||
let t = Tokens::get();
|
||||
let frame = egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6));
|
||||
let r = egui::CentralPanel::default().frame(frame).show(ui, |ui| body(app, ui, false));
|
||||
if class == egui::ViewportClass::EmbeddedWindow {
|
||||
// Floating inside the app: its handles are the main window's, as the panel's are.
|
||||
crate::scenario::publish_rect("agent_chat:window", r.response.rect);
|
||||
app.agent_chat.panel_rect = Some(r.response.rect);
|
||||
return;
|
||||
}
|
||||
let ctx = ui.ctx().clone();
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
|
||||
toggle(app);
|
||||
}
|
||||
// The whole window is the chat, so New chat works anywhere in it.
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) {
|
||||
request_new_chat(app);
|
||||
}
|
||||
let (outer, inner, close) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect, i.viewport().close_requested()));
|
||||
if let (Some(o), Some(n)) = (outer, inner) {
|
||||
app.preferences.agent_chat_window = Some([o.min.x, o.min.y, n.width(), n.height()]);
|
||||
}
|
||||
if close {
|
||||
app.agent_chat.open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the chat's own window should become active because the pointer is over its text box:
|
||||
/// only when it is in a window of its own that isn't active and the app's main window is (never
|
||||
/// taking the focus from another app).
|
||||
pub fn take_focus_for_text(popped: bool, over_text: bool, chat_active: Option<bool>, main_active: Option<bool>) -> bool {
|
||||
popped && over_text && chat_active == Some(false) && main_active == Some(true)
|
||||
}
|
||||
|
||||
/// A small drawn button: `kind` is `minimize`, `close`, `newchat`, `stopturn`, `pop_out` or `dock`.
|
||||
fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
|
||||
let t = Tokens::get();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||||
@@ -116,13 +196,29 @@ fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
|
||||
}
|
||||
let c = r.center();
|
||||
let s = Stroke::new(1.4, t.text);
|
||||
let p = ui.painter();
|
||||
match kind {
|
||||
"minimize" => {
|
||||
ui.painter().line_segment([c + vec2(-5.0, 4.0), c + vec2(5.0, 4.0)], s);
|
||||
p.line_segment([c + vec2(-5.0, 4.0), c + vec2(5.0, 4.0)], s);
|
||||
}
|
||||
"close" => {
|
||||
ui.painter().line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s);
|
||||
ui.painter().line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s);
|
||||
p.line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s);
|
||||
p.line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s);
|
||||
}
|
||||
// A window with an arrow leaving it by its top right corner.
|
||||
"pop_out" => {
|
||||
p.line_segment([c + vec2(-1.0, -5.0), c + vec2(-5.0, -5.0)], s);
|
||||
p.line_segment([c + vec2(-5.0, -5.0), c + vec2(-5.0, 5.0)], s);
|
||||
p.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, 5.0)], s);
|
||||
p.line_segment([c + vec2(5.0, 5.0), c + vec2(5.0, 1.0)], s);
|
||||
p.line_segment([c + vec2(-1.0, 1.0), c + vec2(5.5, -5.5)], s);
|
||||
p.line_segment([c + vec2(1.5, -5.5), c + vec2(5.5, -5.5)], s);
|
||||
p.line_segment([c + vec2(5.5, -5.5), c + vec2(5.5, -1.5)], s);
|
||||
}
|
||||
// A window with its right side filled: the panel docked.
|
||||
"dock" => {
|
||||
p.rect_stroke(Rect::from_center_size(c, vec2(11.0, 10.0)), 1.0, s, egui::StrokeKind::Middle);
|
||||
p.rect_filled(Rect::from_min_max(c + vec2(1.5, -5.0), c + vec2(5.5, 5.0)), 0.0, t.text);
|
||||
}
|
||||
"newchat" => {
|
||||
// A speech bubble with a plus.
|
||||
@@ -179,16 +275,20 @@ fn strip(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
|
||||
/// The header: a grip to drag the panel to the other side, the title and state, Connect or Stop,
|
||||
/// Minimise and Close.
|
||||
/// Pop out (or Dock, in its own window), Minimise and Close. In its own window the system moves
|
||||
/// and minimises it, so there is no grip and no Minimise.
|
||||
fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
|
||||
let t = Tokens::get();
|
||||
let state = app.agent_chat.state();
|
||||
let popped = app.preferences.agent_chat_popped;
|
||||
ui.horizontal(|ui| {
|
||||
let title = {
|
||||
let (grip, _) = ui.allocate_exact_size(vec2(10.0, 18.0), Sense::hover());
|
||||
for row in 0..3 {
|
||||
for col in 0..2 {
|
||||
ui.painter().circle_filled(grip.left_top() + vec2(2.5 + col as f32 * 5.0, 4.0 + row as f32 * 5.0), 1.1, t.text_dim);
|
||||
let (grip, _) = ui.allocate_exact_size(vec2(if popped { 0.0 } else { 10.0 }, 18.0), Sense::hover());
|
||||
if !popped {
|
||||
for row in 0..3 {
|
||||
for col in 0..2 {
|
||||
ui.painter().circle_filled(grip.left_top() + vec2(2.5 + col as f32 * 5.0, 4.0 + row as f32 * 5.0), 1.1, t.text_dim);
|
||||
}
|
||||
}
|
||||
}
|
||||
let l = ui.label(RichText::new(tr("ui.agent_chat")).strong().size(14.0));
|
||||
@@ -203,14 +303,16 @@ fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
|
||||
}
|
||||
ui.label(RichText::new(s).color(state_color(state)).small());
|
||||
// Drag the header across the window to dock on the other side.
|
||||
let drag = ui.interact(title, Id::new("sc_agent_chat_grip"), Sense::drag());
|
||||
crate::scenario::publish_handle("agent_chat:grip", title.center());
|
||||
dock_drag(app, ui, &drag, left);
|
||||
if !popped {
|
||||
let drag = ui.interact(title, Id::new("sc_agent_chat_grip"), Sense::drag());
|
||||
crate::scenario::publish_handle("agent_chat:grip", title.center());
|
||||
dock_drag(app, ui, &drag, left);
|
||||
}
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if glyph_button(ui, "close", tr("ui.agent_chat_close")).clicked() {
|
||||
app.agent_chat.open = false;
|
||||
}
|
||||
if glyph_button(ui, "minimize", tr("ui.agent_chat_minimize")).clicked() {
|
||||
if !popped && glyph_button(ui, "minimize", tr("ui.agent_chat_minimize")).clicked() {
|
||||
app.preferences.agent_chat_minimized = true;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -224,6 +326,13 @@ fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
|
||||
request_new_chat(app);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let (kind, tip) = if popped { ("dock", "ui.agent_chat_dock") } else { ("pop_out", "ui.agent_chat_pop_out") };
|
||||
if glyph_button(ui, kind, tr(tip)).clicked() {
|
||||
app.preferences.agent_chat_popped = !popped;
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let b = if state.running() {
|
||||
ui.button(tr("ui.agent_chat_stop")).on_hover_text(tr("ui.agent_chat_stop_tip"))
|
||||
@@ -345,6 +454,13 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
};
|
||||
let r = ui.add(edit);
|
||||
crate::scenario::publish_rect("agent_chat:input", r.rect);
|
||||
// macOS shows a window's cursors only while it is the active window: over the text box of a
|
||||
// chat window that isn't, the pointer stays an arrow. Coming from the main window, the chat
|
||||
// window becomes active, so the text cursor shows and the first click places the caret.
|
||||
let (chat_active, main_active) = (ui.ctx().input(|i| i.viewport().focused), ui.ctx().input_for(egui::ViewportId::ROOT, |i| i.viewport().focused));
|
||||
if take_focus_for_text(app.preferences.agent_chat_popped, r.hovered(), chat_active, main_active) {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
}
|
||||
let enter = r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift);
|
||||
if esc {
|
||||
cancel(app);
|
||||
|
||||
@@ -183,12 +183,19 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
egui::Grid::new("sc_ai_panel_grid").num_columns(2).spacing([16.0, 6.0]).show(ui, |ui| {
|
||||
ui.label(tr("ui.agent_chat_side"));
|
||||
ui.horizontal(|ui| {
|
||||
let side = &mut app.preferences.agent_chat_side;
|
||||
if ui.radio(side == "left", tr("ui.agent_chat_left")).clicked() {
|
||||
*side = "left".into();
|
||||
let p = &mut app.preferences;
|
||||
let (left, popped) = (p.agent_chat_side == "left", p.agent_chat_popped);
|
||||
if ui.radio(left && !popped, tr("ui.agent_chat_left")).clicked() {
|
||||
p.agent_chat_side = "left".into();
|
||||
p.agent_chat_popped = false;
|
||||
}
|
||||
if ui.radio(side != "left", tr("ui.agent_chat_right")).clicked() {
|
||||
*side = "right".into();
|
||||
if ui.radio(!left && !popped, tr("ui.agent_chat_right")).clicked() {
|
||||
p.agent_chat_side = "right".into();
|
||||
p.agent_chat_popped = false;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if ui.radio(popped, tr("ui.agent_chat_own_window")).on_hover_text(tr("ui.agent_chat_pop_out")).clicked() {
|
||||
p.agent_chat_popped = true;
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
|
||||
@@ -234,6 +234,109 @@ fn ai_features_and_the_toolbar_button_are_on_by_default() {
|
||||
assert!(p.ai_features && p.ai_toolbar_button);
|
||||
}
|
||||
|
||||
/// Pop out puts the chat in its own window (a native window on the desktop, which can go to
|
||||
/// another screen; embedded here, where the test context has one viewport), and Dock puts it back
|
||||
/// in the side panel. The control channel does the same.
|
||||
#[test]
|
||||
fn the_chat_pops_out_into_its_own_window_and_docks_back() {
|
||||
use crate::scenario::{handle_at, rect_of};
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.agent_chat", json!({"open": true}));
|
||||
h.frames(3);
|
||||
assert!(rect_of("agent_chat:panel").is_some() && rect_of("agent_chat:window").is_none(), "docked by default");
|
||||
assert!(!h.app.preferences.agent_chat_popped);
|
||||
let pop = handle_at("agent_chat:pop_out").expect("a pop-out button in the header");
|
||||
h.call("ui.click", json!({"x": pop.x, "y": pop.y}));
|
||||
h.frames(3);
|
||||
assert!(h.app.preferences.agent_chat_popped);
|
||||
assert!(rect_of("agent_chat:panel").is_none(), "the side panel is gone");
|
||||
assert!(rect_of("agent_chat:window").is_some(), "the chat is in its own window");
|
||||
assert_eq!(status(&h.app)["popped"], json!(true));
|
||||
// A window has its own minimise; the strip and the side drag are the docked panel's.
|
||||
assert!(handle_at("agent_chat:minimize").is_none() && handle_at("agent_chat:grip").is_none());
|
||||
let dock = handle_at("agent_chat:dock").expect("a dock button in the popped-out header");
|
||||
h.call("ui.click", json!({"x": dock.x, "y": dock.y}));
|
||||
h.frames(3);
|
||||
assert!(!h.app.preferences.agent_chat_popped);
|
||||
assert!(rect_of("agent_chat:panel").is_some() && rect_of("agent_chat:window").is_none(), "back in the side panel");
|
||||
h.call("ui.agent_chat", json!({"popped": true}));
|
||||
h.frames(3);
|
||||
assert!(rect_of("agent_chat:window").is_some() && rect_of("agent_chat:panel").is_none());
|
||||
// Closing the chat keeps where it lives: it opens popped out again.
|
||||
h.call("ui.agent_chat", json!({"open": false}));
|
||||
h.frames(2);
|
||||
assert!(rect_of("agent_chat:window").is_none());
|
||||
h.call("ui.agent_chat", json!({"open": true}));
|
||||
h.frames(3);
|
||||
assert!(rect_of("agent_chat:window").is_some());
|
||||
}
|
||||
|
||||
/// The saved place is given to the window once, when it opens; after that the system owns it.
|
||||
/// Given every frame, it chased the system's own rounding: a window that shook a pixel up and
|
||||
/// down (686, 687, 686…) on a Retina screen.
|
||||
#[test]
|
||||
fn the_window_place_is_given_once_when_it_opens() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
app.preferences.agent_chat_window = Some([1967.0, 160.0, 569.0, 686.0]);
|
||||
let first = panel::window_builder(&app);
|
||||
assert_eq!(first.position, Some(egui::pos2(1967.0, 160.0)));
|
||||
assert_eq!(first.inner_size, Some(egui::vec2(569.0, 686.0)));
|
||||
app.agent_chat.window_placed = true;
|
||||
app.preferences.agent_chat_window = Some([1967.0, 160.0, 569.0, 687.0]);
|
||||
let later = panel::window_builder(&app);
|
||||
assert_eq!((later.position, later.inner_size), (None, None), "nothing to move or resize once it is open");
|
||||
assert!(later.title.is_some());
|
||||
// Docked or closed, the next pop-out opens at the saved place again.
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.agent_chat", json!({"open": true, "popped": true}));
|
||||
h.frames(2);
|
||||
assert!(h.app.agent_chat.window_placed);
|
||||
h.call("ui.agent_chat", json!({"popped": false}));
|
||||
h.frames(2);
|
||||
assert!(!h.app.agent_chat.window_placed);
|
||||
h.call("ui.agent_chat", json!({"popped": true}));
|
||||
h.frames(2);
|
||||
h.call("ui.agent_chat", json!({"open": false}));
|
||||
h.frames(2);
|
||||
assert!(!h.app.agent_chat.window_placed);
|
||||
}
|
||||
|
||||
/// macOS shows the text cursor only in the active window: the chat's own window becomes active
|
||||
/// when the pointer comes over its text box from the app's main window, and never takes the focus
|
||||
/// from another app.
|
||||
#[test]
|
||||
fn the_chat_window_takes_the_focus_for_its_text_box_from_the_main_window_only() {
|
||||
assert!(panel::take_focus_for_text(true, true, Some(false), Some(true)));
|
||||
assert!(!panel::take_focus_for_text(true, true, Some(false), Some(false)), "another app is active");
|
||||
assert!(!panel::take_focus_for_text(true, true, Some(true), Some(false)), "already active");
|
||||
assert!(!panel::take_focus_for_text(true, false, Some(false), Some(true)), "not over the text box");
|
||||
assert!(!panel::take_focus_for_text(false, true, Some(false), Some(true)), "docked: the main window shows it");
|
||||
assert!(!panel::take_focus_for_text(true, true, None, Some(true)), "unknown: leave it");
|
||||
}
|
||||
|
||||
/// Whether the chat is popped out, and its window's place and size, are preferences; ones saved
|
||||
/// before they existed load docked, and an unusable saved place is ignored.
|
||||
#[test]
|
||||
fn the_popped_out_window_and_its_place_are_remembered() {
|
||||
use crate::prefs::Prefs;
|
||||
let mut p = Prefs::default();
|
||||
assert!(!p.agent_chat_popped && p.agent_chat_window.is_none());
|
||||
p.agent_chat_popped = true;
|
||||
p.agent_chat_window = Some([-1600.0, 80.0, 420.0, 700.0]);
|
||||
let back: Prefs = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
|
||||
assert!(back.agent_chat_popped);
|
||||
assert_eq!(back.agent_chat_window, Some([-1600.0, 80.0, 420.0, 700.0]), "a place on a screen left of the main one is kept");
|
||||
let mut old = serde_json::to_value(Prefs::default()).unwrap();
|
||||
old.as_object_mut().unwrap().remove("agent_chat_popped");
|
||||
old.as_object_mut().unwrap().remove("agent_chat_window");
|
||||
let old: Prefs = serde_json::from_value(old).unwrap();
|
||||
assert!(!old.agent_chat_popped && old.agent_chat_window.is_none());
|
||||
assert_eq!(panel::window_place(Some([10.0, 20.0, 400.0, 600.0])), Some(([10.0, 20.0], [400.0, 600.0])));
|
||||
for bad in [[f32::NAN, 0.0, 400.0, 600.0], [0.0, 0.0, 10.0, 600.0], [0.0, 0.0, 400.0, f32::INFINITY], [1e9, 0.0, 400.0, 600.0]] {
|
||||
assert_eq!(panel::window_place(Some(bad)), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// "Ask every time": a modelling command waits for Allow; "Always allow" lets the same command
|
||||
/// through from then on for this agent. "Allow everything": even a save runs without a question.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! The Boundary Surface dialog (#763): a loop of sketch curves becomes the loop patch, edges and
|
||||
//! curves across become a network with its continuity and operation, curves that meet end to end
|
||||
//! are one chain, and Edit Feature brings a network back as it was.
|
||||
use crate::dialogs::Kind;
|
||||
use crate::dialogs_surface::Surf;
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Map, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::{FeatureKind, Operation, kernel::Continuity};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
fn ok(h: &mut Harness) {
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
// An edit is applied off the UI thread: give it the frames it takes.
|
||||
for _ in 0..200 {
|
||||
if h.app.dialog.is_none() {
|
||||
break;
|
||||
}
|
||||
h.frames(1);
|
||||
}
|
||||
assert!(h.app.dialog.is_none(), "{:?} {:?} {:?}", h.app.preview.error, h.app.dialog.as_ref().map(|d| (&d.error, &d.inputs)), h.app.session.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_of_sketch_curves_is_the_loop_patch() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Loop"})).unwrap();
|
||||
for (id, a, b) in [("s1", [0, 0], [10, 0]), ("s2", [10, 0], [10, 10]), ("s3", [10, 10], [0, 10]), ("s4", [0, 10], [0, 0])] {
|
||||
h.app.run("sketch.line", json!({"points":[a, b],"ids":[id]})).unwrap();
|
||||
}
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.step(&json!({"call":"ui.start","params":{"command":"surface.boundary"}})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.widget("Cross curves").is_some() && h.widget("Continuity").is_some() && h.widget("Operation").is_some());
|
||||
let d = h.app.dialog.as_mut().unwrap();
|
||||
d.inputs[0].items = ["s1", "s2", "s3", "s4"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
|
||||
ok(&mut h);
|
||||
let f = h.app.session.doc.features.last().unwrap();
|
||||
let FeatureKind::SurfaceBoundary { curves, orders, .. } = &f.kind else { panic!("{:?}", f.kind) };
|
||||
assert_eq!(curves, &["s1", "s2", "s3", "s4"]);
|
||||
assert!(orders.iter().all(|o| *o == Continuity::G1), "{orders:?}");
|
||||
assert_eq!(h.app.session.world_state().bodies.len(), 1);
|
||||
}
|
||||
|
||||
/// A quarter cylinder sheet (its top edge open at y = 10, z = 0) and a 3D line above it.
|
||||
fn cylinder_and_line(h: &mut Harness) -> String {
|
||||
h.app.run("sketch.create", json!({"plane":"YZ","name":"Arc"})).unwrap();
|
||||
let c = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
|
||||
let r = h.app.run("sketch.arc.three_point", json!({"start":[0,-10],"end":[10,0],"through":[c,-c]})).unwrap();
|
||||
let arc = r["created"]["curves"][0].as_str().unwrap().to_string();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("surface.extrude", json!({"sketch":"Arc","curves":[arc],"distance":10})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Top"})).unwrap();
|
||||
h.app.run("sketch.options", json!({"sketch_3d": true})).unwrap();
|
||||
let r = h.app.run("sketch.line3d", json!({"points":[[0,3,12],[10,3,12]]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
r["curves"][0].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_and_curves_make_a_network_and_come_back_in_an_edit() {
|
||||
let mut h = Harness::new();
|
||||
let top = cylinder_and_line(&mut h);
|
||||
let rim = crate::dialogs::edge_sel(&h.app.session, Vec3::new(5.0, 10.0, 0.0)).unwrap();
|
||||
let Sel::Edge { body, .. } = &rim else { panic!("{rim:?}") };
|
||||
let body = body.clone();
|
||||
h.step(&json!({"call":"ui.start","params":{"command":"surface.boundary"}})).unwrap();
|
||||
h.frames(3);
|
||||
let d = h.app.dialog.as_mut().unwrap();
|
||||
d.inputs[0].items = vec![rim.clone(), Sel::SketchCurve { id: top.clone() }];
|
||||
let Kind::Surface(Surf::Boundary { continuity, join }) = &mut d.kind else { panic!("{:?}", d.kind) };
|
||||
*continuity = 2;
|
||||
*join = true;
|
||||
ok(&mut h);
|
||||
let name = h.app.session.doc.features.last().unwrap().name.clone();
|
||||
let network = |h: &Harness| match &h.app.session.doc.find_feature(&name).unwrap().kind {
|
||||
FeatureKind::SurfaceNetwork { u, v, operation } => (u.clone(), v.clone(), *operation),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let (u, v, operation) = network(&h);
|
||||
assert_eq!((u.len(), v.len(), operation), (2, 0, Operation::Join));
|
||||
assert_eq!(u[0].body.as_ref().map(|b| b.to_string()), Some(body.clone()));
|
||||
assert_eq!(u[0].continuity, Continuity::G2);
|
||||
assert_eq!(u[1].curves, vec![top.clone()]);
|
||||
// Joined into the sheet: one body, two faces.
|
||||
let st = h.app.session.world_state();
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
assert_eq!(st.body(&body).map(|b| b.body.face_count()), Some(2));
|
||||
drop(st);
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":name}})).unwrap();
|
||||
h.frames(8);
|
||||
let d = h.app.dialog.as_ref().unwrap();
|
||||
assert!(matches!(d.kind, Kind::Surface(Surf::Boundary { continuity: 2, join: true })), "{:?}", d.kind);
|
||||
assert_eq!(d.inputs[0].items.len(), 2, "{:?}", d.inputs[0].items);
|
||||
ok(&mut h);
|
||||
let (u2, v2, op2) = network(&h);
|
||||
assert_eq!((u2.len(), v2.len(), op2), (2, 0, Operation::Join));
|
||||
assert_eq!((u2[0].continuity, &u2[1].curves), (Continuity::G2, &vec![top]));
|
||||
}
|
||||
|
||||
/// Picks that meet end to end in one sketch are one curve of the network; a pick elsewhere is
|
||||
/// another.
|
||||
#[test]
|
||||
fn curves_that_meet_are_one_chain() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Net"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,0],[5,0]],"ids":["a1"]})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[5,0],[10,0]],"ids":["a2"]})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,8],[10,8]],"ids":["b"]})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,0],[0,8]],"ids":["c"]})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[10,0],[10,8]],"ids":["d"]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let sketch = h.app.session.doc.find_feature("Net").unwrap().id;
|
||||
let k = Surf::Boundary { continuity: 0, join: false };
|
||||
let (_, mut inputs) = crate::dialogs_surface::start(&h.app, "surface.boundary").unwrap();
|
||||
inputs[0].items = ["a2", "b", "a1"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
|
||||
inputs[1].items = ["c", "d"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
|
||||
let cmds = crate::dialogs_surface::commands(&h.app, &k, &inputs, &Map::new()).unwrap();
|
||||
let (id, p) = &cmds[0];
|
||||
assert_eq!(id, "surface.boundary");
|
||||
assert_eq!(p["u"], json!([{"sketch": sketch, "curves": ["a2", "a1"]}, {"sketch": sketch, "curves": ["b"]}]), "{p}");
|
||||
assert_eq!(p["v"], json!([{"sketch": sketch, "curves": ["c"]}, {"sketch": sketch, "curves": ["d"]}]), "{p}");
|
||||
// And it builds: the chain a1–a2 is one boundary of the patch.
|
||||
h.app.run("surface.boundary", p.clone()).unwrap();
|
||||
assert_eq!(h.app.session.world_state().bodies.len(), 1);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Revolve, Sweep and
|
||||
//! Loft, Offset and Ruled Surface, Patch, Stitch, Thicken, Extend, Trim, Untrim and Split Face. Each has a live preview and Edit Feature.
|
||||
//! Loft, Offset and Ruled Surface, Boundary Surface, Patch, Stitch, Thicken, Extend, Trim, Untrim
|
||||
//! and Split Face. Each has a live preview and Edit Feature.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
@@ -27,6 +28,13 @@ pub enum Surf {
|
||||
},
|
||||
Sweep(crate::dialogs_sweep::SweepOpts),
|
||||
Loft,
|
||||
/// `continuity`: an index into [`PATCH_CONTINUITY`] (for a loop of sketch curves, how much of
|
||||
/// the sketch's plane each side repeats; for picked edges, how the surface meets the faces
|
||||
/// beside them); `join`: sew the surface into the body of its edges.
|
||||
Boundary {
|
||||
continuity: usize,
|
||||
join: bool,
|
||||
},
|
||||
/// `continuity`: an index into [`PATCH_CONTINUITY`] (how a patch on edges meets the faces
|
||||
/// beside its loop).
|
||||
Patch {
|
||||
@@ -74,6 +82,7 @@ impl Surf {
|
||||
Surf::Revolve { .. } => "SURFACE REVOLVE",
|
||||
Surf::Sweep(_) => "SURFACE SWEEP",
|
||||
Surf::Loft => "SURFACE LOFT",
|
||||
Surf::Boundary { .. } => "BOUNDARY SURFACE",
|
||||
Surf::Patch { .. } => "PATCH",
|
||||
Surf::Unstitch => "UNSTITCH",
|
||||
Surf::ReverseNormal => "REVERSE NORMAL",
|
||||
@@ -104,6 +113,9 @@ impl Surf {
|
||||
/// How a patch on edges meets the faces beside its loop (labels, and the `continuity` parameter).
|
||||
const PATCH_CONTINUITY: [(&str, &str); 3] = [("Position (G0)", "g0"), ("Tangent (G1)", "g1"), ("Curvature (G2)", "g2")];
|
||||
|
||||
/// What a boundary surface becomes: a body of its own, or part of the body of its edges.
|
||||
const BOUNDARY_OPS: [&str; 2] = ["New Body", "Join"];
|
||||
|
||||
/// Which holes Untrim takes away: every one, the picked ones, or none (the outer boundary only).
|
||||
const UNTRIM_HOLES: [&str; 3] = ["All", "Picked", "None"];
|
||||
|
||||
@@ -128,6 +140,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
|
||||
]
|
||||
}
|
||||
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
|
||||
Surf::Boundary { .. } => vec![SelInput::new("Curves", CURVES | EDGES, true), SelInput::new("Cross curves", CURVES | EDGES, true)],
|
||||
Surf::Patch { .. } => vec![SelInput::new("Boundary", PROFILES | EDGES, true), SelInput::new("Pass through", VERTICES | FACES, true)],
|
||||
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
|
||||
Surf::ReverseNormal => vec![SelInput::new("Surface, or faces to turn over", BODIES | FACES, true)],
|
||||
@@ -155,6 +168,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
|
||||
"surface.sweep" => Surf::Sweep(crate::dialogs_sweep::SweepOpts::default()),
|
||||
"surface.loft" => Surf::Loft,
|
||||
"surface.boundary" => Surf::Boundary { continuity: 1, join: false },
|
||||
"surface.patch" => Surf::Patch { continuity: 0 },
|
||||
"surface.unstitch" => Surf::Unstitch,
|
||||
"surface.reverse_normal" => Surf::ReverseNormal,
|
||||
@@ -264,6 +278,16 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
|
||||
combo(ui, "pa_cont", &PATCH_CONTINUITY.map(|(label, _)| label), continuity);
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Boundary { continuity, join } => {
|
||||
row_label(ui, "Continuity");
|
||||
combo(ui, "bo_cont", &PATCH_CONTINUITY.map(|(label, _)| label), continuity);
|
||||
ui.end_row();
|
||||
row_label(ui, "Operation");
|
||||
let mut op = usize::from(*join);
|
||||
combo(ui, "bo_op", &BOUNDARY_OPS, &mut op);
|
||||
*join = op == 1;
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Sweep(opts) => enter |= crate::dialogs_sweep::rows(ui, opts, "ss"),
|
||||
Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {}
|
||||
}
|
||||
@@ -342,6 +366,98 @@ fn tool(app: &SolveApp, sel: Option<&Sel>) -> Result<(&'static str, Value), Stri
|
||||
}
|
||||
}
|
||||
|
||||
/// One curve of a boundary surface as the dialog picks it: sketch curves of one sketch, or edges
|
||||
/// of one body, that meet end to end.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Chain {
|
||||
Sketch { sketch: u64, ids: Vec<String> },
|
||||
Edges { body: String, points: Vec<Vec3> },
|
||||
}
|
||||
|
||||
/// The picks grouped into curves: picks of one sketch (or one body) whose ends meet run on into
|
||||
/// one chain, in the order they were first picked; anything else is a curve of its own.
|
||||
fn chains(app: &SolveApp, picks: &[Sel]) -> Result<Vec<Chain>, String> {
|
||||
let st = app.session.world_state();
|
||||
// Each pick: what it belongs to (a sketch or a body), its ends, and how it joins a chain.
|
||||
let mut parts: Vec<(Chain, [Vec3; 2])> = Vec::with_capacity(picks.len());
|
||||
for x in picks {
|
||||
match x {
|
||||
Sel::SketchCurve { id } => {
|
||||
let sketch = crate::dialogs::curves_sketch(app, std::slice::from_ref(id), None).ok_or("a picked curve is no longer in a sketch")?;
|
||||
let ss = st.sketch(sketch).ok_or("a picked curve's sketch has not been solved")?;
|
||||
let ends = if let Some(ci) = ss.sketch.curve_index(id) {
|
||||
let segs = ss.sketch.segs(ci);
|
||||
match (segs.first(), segs.last()) {
|
||||
(Some(a), Some(b)) => [ss.plane.to_world(a.start()), ss.plane.to_world(b.end())],
|
||||
_ => return Err(format!("curve `{id}` has no geometry")),
|
||||
}
|
||||
} else {
|
||||
let w = ss.sketch.wire_index(id).and_then(|wi| ss.sketch.wires.get(wi)).ok_or("a picked curve is no longer in a sketch")?;
|
||||
match (w.pts.first(), w.pts.last()) {
|
||||
(Some(a), Some(b)) => [*a, *b],
|
||||
_ => return Err(format!("curve `{id}` has no geometry")),
|
||||
}
|
||||
};
|
||||
parts.push((Chain::Sketch { sketch, ids: vec![id.clone()] }, ends));
|
||||
}
|
||||
Sel::Edge { body, index, point } => {
|
||||
let b = st.body(body).ok_or("a picked edge's body is gone")?;
|
||||
let m = b.mesh();
|
||||
let e = m.edges.get(*index).ok_or("a picked edge is gone")?;
|
||||
let (Some(a), Some(z)) = (e.first(), e.last()) else { return Err("a picked edge has no points".into()) };
|
||||
parts.push((Chain::Edges { body: body.clone(), points: vec![*point] }, [*a, *z]));
|
||||
}
|
||||
_ => return Err("pick sketch curves or edges".into()),
|
||||
}
|
||||
}
|
||||
let same_source = |a: &Chain, b: &Chain| match (a, b) {
|
||||
(Chain::Sketch { sketch: x, .. }, Chain::Sketch { sketch: y, .. }) => x == y,
|
||||
(Chain::Edges { body: x, .. }, Chain::Edges { body: y, .. }) => x == y,
|
||||
_ => false,
|
||||
};
|
||||
let meet = |a: &[Vec3; 2], b: &[Vec3; 2]| a.iter().any(|p| b.iter().any(|q| p.dist(*q) <= 1e-6 * (1.0 + p.len())));
|
||||
// Union the picks that meet, keeping the first pick of each chain in front.
|
||||
let mut group: Vec<usize> = (0..parts.len()).collect();
|
||||
fn root(group: &[usize], mut i: usize) -> usize {
|
||||
while let Some(&g) = group.get(i) {
|
||||
if g == i {
|
||||
break;
|
||||
}
|
||||
i = g;
|
||||
}
|
||||
i
|
||||
}
|
||||
for i in 0..parts.len() {
|
||||
for j in 0..i {
|
||||
let (Some((ci, ei)), Some((cj, ej))) = (parts.get(i), parts.get(j)) else { continue };
|
||||
if same_source(ci, cj) && meet(ei, ej) {
|
||||
let (ri, rj) = (root(&group, i), root(&group, j));
|
||||
if let Some(g) = group.get_mut(ri.max(rj)) {
|
||||
*g = ri.min(rj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<(usize, Chain)> = Vec::new();
|
||||
for (i, (part, _)) in parts.into_iter().enumerate() {
|
||||
let r = root(&group, i);
|
||||
match out.iter_mut().find(|(k, _)| *k == r) {
|
||||
Some((_, Chain::Sketch { ids, .. })) => {
|
||||
if let Chain::Sketch { ids: more, .. } = part {
|
||||
ids.extend(more);
|
||||
}
|
||||
}
|
||||
Some((_, Chain::Edges { points, .. })) => {
|
||||
if let Chain::Edges { points: more, .. } = part {
|
||||
points.extend(more);
|
||||
}
|
||||
}
|
||||
None => out.push((r, part)),
|
||||
}
|
||||
}
|
||||
Ok(out.into_iter().map(|(_, c)| c).collect())
|
||||
}
|
||||
|
||||
/// The command a surface dialog makes.
|
||||
pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
|
||||
let need = |i: usize, what: &str| if items(inputs, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) };
|
||||
@@ -407,6 +523,48 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
.collect::<Result<_, String>>()?;
|
||||
("surface.loft", json!({ "sections": sections }))
|
||||
}
|
||||
Surf::Boundary { continuity, join } => {
|
||||
need(0, "the curves (sketch curves or edges)")?;
|
||||
let word = PATCH_CONTINUITY.get(*continuity).map_or("g0", |(_, w)| *w);
|
||||
let across = items(inputs, 1);
|
||||
let picks = items(inputs, 0);
|
||||
let sketch_ids: Vec<String> =
|
||||
picks.iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
|
||||
// Two to four sketch curves of one sketch and nothing across: the loop patch.
|
||||
let looped = across.is_empty() && !*join && sketch_ids.len() == picks.len() && (2..=4).contains(&picks.len());
|
||||
match looped.then(|| crate::dialogs::curves_sketch(app, &sketch_ids, None)).flatten() {
|
||||
Some(sk) => {
|
||||
let mut p = json!({"sketch": sk, "curves": sketch_ids});
|
||||
// Orders that differ from side to side (kept from the feature) stand as they are.
|
||||
if !extra.contains_key("continuity") {
|
||||
p["continuity"] = json!(word);
|
||||
}
|
||||
("surface.boundary", p)
|
||||
}
|
||||
None => {
|
||||
let curve = |c: &Chain| match c {
|
||||
Chain::Sketch { sketch, ids } => json!({"sketch": sketch, "curves": ids}),
|
||||
Chain::Edges { body, points } => {
|
||||
let mut v = json!({"body": body, "edges": points.iter().map(|p| pt(*p)).collect::<Vec<_>>()});
|
||||
if *continuity > 0 {
|
||||
v["continuity"] = json!(word);
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
let u: Vec<Value> = chains(app, picks)?.iter().map(curve).collect();
|
||||
let v: Vec<Value> = chains(app, across)?.iter().map(curve).collect();
|
||||
let mut p = json!({"u": u});
|
||||
if !v.is_empty() {
|
||||
p["v"] = json!(v);
|
||||
}
|
||||
if *join {
|
||||
p["operation"] = json!("join");
|
||||
}
|
||||
("surface.boundary", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
Surf::Patch { continuity } => {
|
||||
need(0, "a boundary (sketch profiles or a loop of edges)")?;
|
||||
let profiles: Vec<(u64, usize)> =
|
||||
@@ -629,6 +787,46 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
],
|
||||
)
|
||||
}
|
||||
FeatureKind::SurfaceBoundary { curves, orders, crown, samples, .. } => {
|
||||
let first = orders.first().copied().unwrap_or(solidcraft_engine::doc::kernel::Continuity::G1);
|
||||
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == first.as_str());
|
||||
// What the dialog does not show is kept as it was: a crown, the stations, and orders
|
||||
// that differ from side to side or that the list does not offer (G3).
|
||||
let mut extra = Map::new();
|
||||
if *crown != 0.0 {
|
||||
extra.insert("crown".into(), json!(crown));
|
||||
}
|
||||
if *samples != solidcraft_engine::doc::BOUNDARY_SAMPLES {
|
||||
extra.insert("samples".into(), json!(samples));
|
||||
}
|
||||
if index.is_none() || orders.iter().any(|o| *o != first) {
|
||||
extra.insert("continuity".into(), json!(orders.iter().map(|o| o.as_str()).collect::<Vec<_>>()));
|
||||
}
|
||||
let k = Surf::Boundary { continuity: index.unwrap_or(1), join: false };
|
||||
let mut ins = inputs(&k);
|
||||
if let Some(inp) = ins.first_mut() {
|
||||
inp.items = curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
|
||||
}
|
||||
return Some((Kind::Surface(k), ins, extra));
|
||||
}
|
||||
FeatureKind::SurfaceNetwork { u, v, operation } => {
|
||||
// A network with a point for a side has nothing to pick it with here.
|
||||
if u.iter().chain(v).any(|c| c.point.is_some()) {
|
||||
return None;
|
||||
}
|
||||
let sels = |list: &[solidcraft_engine::doc::NetworkCurve]| -> Vec<Sel> {
|
||||
list.iter()
|
||||
.flat_map(|c| {
|
||||
let mut out: Vec<Sel> = c.curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
|
||||
out.extend(c.edges.iter().filter_map(edge));
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let g = u.iter().chain(v).find(|c| c.body.is_some()).map_or(solidcraft_engine::doc::kernel::Continuity::G0, |c| c.continuity);
|
||||
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == g.as_str()).unwrap_or(0);
|
||||
(Surf::Boundary { continuity: index, join: *operation == solidcraft_engine::doc::Operation::Join }, vec![sels(u), sels(v)])
|
||||
}
|
||||
FeatureKind::SurfaceLoft { sections, .. } => {
|
||||
(Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch.0, sec.profiles.as_ref(), &sec.curves)).collect()])
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.add_space(24.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.hyperlink_to(app.tr("home.documentation"), crate::help::DOCS_URL);
|
||||
ui.hyperlink_to(app.tr("home.report_issue"), "https://github.com/bherbruck/solidcraft/issues/new");
|
||||
ui.hyperlink_to(app.tr("home.report_issue"), "https://github.com/tangency-labs/solidcraft/issues/new");
|
||||
ui.hyperlink_to(app.tr("home.whats_new"), "https://github.com/bherbruck/solidcraft/releases/latest");
|
||||
if ui.link(app.tr("ui.keyboard_shortcuts")).clicked() {
|
||||
app.keymap.open = true;
|
||||
|
||||
@@ -37,6 +37,7 @@ static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!(concat!(env!
|
||||
static SPANISH: Catalog = Catalog::new("es", "Español (España)", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/es.json")));
|
||||
static FRENCH: Catalog = Catalog::new("fr", "Français", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/fr.json")));
|
||||
static GALICIAN: Catalog = Catalog::new("gl", "Galego", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/gl.json")));
|
||||
static ITALIAN: Catalog = Catalog::new("it", "Italiano", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/it.json")));
|
||||
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ja.json")));
|
||||
static KOREAN: Catalog = Catalog::new("ko", "한국어", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/locales/ko.json")));
|
||||
static PORTUGUESE_BR: Catalog =
|
||||
@@ -55,6 +56,7 @@ pub const LOCALES: &[&Catalog] = &[
|
||||
&SPANISH,
|
||||
&FRENCH,
|
||||
&GALICIAN,
|
||||
&ITALIAN,
|
||||
&JAPANESE,
|
||||
&KOREAN,
|
||||
&PORTUGUESE_BR,
|
||||
@@ -238,8 +240,8 @@ mod tests {
|
||||
ENGLISH.messages().values().map(String::as_str).collect()
|
||||
}
|
||||
|
||||
/// Catalogs delivered complete. English is the source catalog; Japanese was completed
|
||||
/// upstream, Chinese and Czech land complete here. German, Korean, Russian, French and Ukrainian
|
||||
/// Catalogs delivered complete. English is the source catalog; Czech, Italian, Japanese and Chinese
|
||||
/// are complete. German, Korean, Russian, French and Ukrainian
|
||||
/// are partial (German and Korean predate the coverage guards; Russian, French and Ukrainian
|
||||
/// are kept partial so a new label doesn't need more translations), so their gaps fall back
|
||||
/// to English and are reported by `cargo xtask i18n` without failing the build, while
|
||||
@@ -392,6 +394,39 @@ mod tests {
|
||||
assert_eq!(Locale(&PARTIAL).text("ui.extrude"), "Extrude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_labels_templates_and_names_preserve_technical_values() {
|
||||
assert!(is_supported("it"));
|
||||
let it = Locale::for_language("it");
|
||||
assert_eq!(it.name(), "Italiano");
|
||||
for (source, expected) in [
|
||||
("Sketch", "Schizzo"),
|
||||
("Extrude", "Estrusione"),
|
||||
("Fillet", "Raccordo"),
|
||||
("Chamfer", "Smusso"),
|
||||
("Body", "Corpo"),
|
||||
("New Component", "Nuovo componente"),
|
||||
("CONSTRAINTS", "VINCOLI"),
|
||||
("Draft", "Sformo"),
|
||||
("Counterbore", "Lamatura"),
|
||||
("Countersink", "Svasatura"),
|
||||
] {
|
||||
assert_eq!(it.label(source), expected, "{source}");
|
||||
}
|
||||
assert_eq!(it.dynamic("Sketch12"), "Schizzo12");
|
||||
assert_eq!(it.dynamic("Body2"), "Corpo2");
|
||||
assert_eq!(it.dynamic("Component3"), "Componente3");
|
||||
assert_eq!(it.dynamic("Units: mm"), "Unità: mm");
|
||||
assert_eq!(it.dynamic("4 degrees of freedom"), "4 gradi di libertà");
|
||||
assert_eq!(it.given_name("Fillet"), "Fillet");
|
||||
assert_eq!(it.label("solid.extrude"), "solid.extrude");
|
||||
assert_eq!(it.format("document.save_changes", &[("name", "Staffa {v2}")]), "Salvare le modifiche a Staffa {v2}?");
|
||||
assert_eq!(
|
||||
it.format("ui.command_palette_hint", &[("example", "solid.extrude {\"distance\": 20}")]),
|
||||
"Digita un comando, ad es. solid.extrude {\"distance\": 20} — oppure premi S per cercare"
|
||||
);
|
||||
}
|
||||
|
||||
/// `label()`'s id fallback (an argument that matches no other entry's English text is
|
||||
/// looked up as an id directly) is how the Sketch Palette reaches `ui.palette_profile` and
|
||||
/// `ui.palette_points` without colliding with "Profile" / "Points" elsewhere (see the
|
||||
|
||||
@@ -21,6 +21,24 @@ fn japanese_dialog_keeps_english_scenario_handles() {
|
||||
assert!(h.widget("Cancel").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_dialog_keeps_english_scenario_handles() {
|
||||
let mut h = Harness::new();
|
||||
h.app.preferences.language = "it".into();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.start", json!({"command":"sketch.create"}));
|
||||
h.frames(3);
|
||||
assert!(h.widget("Piano").is_some());
|
||||
assert!(h.widget("Annulla").is_some());
|
||||
assert!(!scenario::handles("dialog:Plane").is_empty());
|
||||
assert_eq!(crate::i18n::tr("ui.preferences"), "Preferences");
|
||||
|
||||
h.app.preferences.language = "en".into();
|
||||
h.frames(3);
|
||||
assert!(h.widget("Plane").is_some());
|
||||
assert!(h.widget("Cancel").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_palette_profile_and_points_rows_show_their_own_translation() {
|
||||
// ui.palette_profile / ui.palette_points are reached by id, not by the usual English-text
|
||||
@@ -67,6 +85,7 @@ fn window_chrome_renders_in_the_active_language() {
|
||||
("ko", "브라우저", "선택 필터", "면"),
|
||||
("ru", "БРАУЗЕР", "Фильтр выделения", "Грани"),
|
||||
("fr", "NAVIGATEUR", "Filtre de sélection", "Faces"),
|
||||
("it", "STRUTTURA", "Filtro di selezione", "Facce"),
|
||||
("uk", "БРАУЗЕР", "Фільтр виділення", "Грані"),
|
||||
] {
|
||||
let mut h = Harness::new();
|
||||
@@ -135,6 +154,22 @@ fn home_page_renders_in_the_active_language() {
|
||||
}
|
||||
}
|
||||
let cases = [
|
||||
(
|
||||
"it",
|
||||
[
|
||||
"Nuovo progetto",
|
||||
"Apri…",
|
||||
"Documentazione",
|
||||
"Segnala un problema",
|
||||
"Novità",
|
||||
"Scorciatoie da tastiera",
|
||||
"Preferenze",
|
||||
"Recenti",
|
||||
"Esempi",
|
||||
"Piastra di esempio",
|
||||
],
|
||||
"non presente\nAperto 2 h fa",
|
||||
),
|
||||
(
|
||||
"ja",
|
||||
[
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::{SolveApp, dialogs, help};
|
||||
|
||||
const MAX_BODY: usize = 24_000;
|
||||
const MAX_IMAGE_PIXELS: usize = 16_000_000;
|
||||
const ISSUE_URL: &str = "https://github.com/bherbruck/solidcraft/issues/new";
|
||||
const ISSUE_URL: &str = "https://github.com/tangency-labs/solidcraft/issues/new";
|
||||
|
||||
thread_local! {
|
||||
static CAD_INPUT_BLOCKED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
|
||||
@@ -106,6 +106,8 @@ mod history_off_thread_tests;
|
||||
pub use file_picker::FilePickerFuture;
|
||||
#[cfg(test)]
|
||||
mod blend_arrow_tests;
|
||||
#[cfg(test)]
|
||||
mod boundary_dialog_tests;
|
||||
pub(crate) mod display_lod;
|
||||
#[cfg(test)]
|
||||
mod file_load_ui_tests;
|
||||
|
||||
@@ -307,7 +307,8 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
|
||||
// (its End was lost); a stuck flag would pan future mouse input.
|
||||
app.viewport.trackpad_gesture = false;
|
||||
}
|
||||
let pan = if trackpad_pan { raw.pan } else { egui::Vec2::ZERO } + raw.mouse_pan;
|
||||
let mut pan = if trackpad_pan { raw.pan } else { egui::Vec2::ZERO } + raw.mouse_pan;
|
||||
pan.x *= crate::prefs::pan_sign_x(app);
|
||||
let scroll = if app.viewport.wheel_zoom_raw_until_idle || (trackpad_pan && app.viewport.trackpad_until_idle) {
|
||||
raw.zoom + if trackpad_pan { 0.0 } else { raw.point_zoom }
|
||||
} else {
|
||||
|
||||
+35
-32
@@ -11,6 +11,7 @@
|
||||
use egui::{Modifiers, Pos2, RichText};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
pub use solidcraft_engine::doc::display_units::{format_length, length_number, length_precision, unit_scale};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
/// The key the apps keep the preferences under in eframe's storage.
|
||||
@@ -320,6 +321,8 @@ pub struct Prefs {
|
||||
pub smooth_wheel_zoom: bool,
|
||||
/// Two-finger trackpad scroll pans (off: it zooms like the wheel); pinch always zooms.
|
||||
pub trackpad_pan: bool,
|
||||
/// Horizontal trackpad and thumb-wheel scrolling pans the view the other way (#2007).
|
||||
pub pan_reverse_x: bool,
|
||||
/// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast.
|
||||
pub look_at_anim: bool,
|
||||
pub look_at_speed: f32,
|
||||
@@ -366,6 +369,11 @@ pub struct Prefs {
|
||||
pub agent_chat_side: String,
|
||||
pub agent_chat_width: f32,
|
||||
pub agent_chat_minimized: bool,
|
||||
/// Whether the chat is in its own window instead of the side panel, and that window's place
|
||||
/// and size as the system last reported them (`[x, y, width, height]`, points; `x` can be
|
||||
/// negative or past the main screen when the window sits on another one).
|
||||
pub agent_chat_popped: bool,
|
||||
pub agent_chat_window: Option<[f32; 4]>,
|
||||
/// Changes the agent may make for one message.
|
||||
pub agent_chat_turn_limit: u32,
|
||||
}
|
||||
@@ -431,6 +439,7 @@ impl Default for Prefs {
|
||||
zoom_to_cursor: true,
|
||||
smooth_wheel_zoom: true,
|
||||
trackpad_pan: true,
|
||||
pan_reverse_x: false,
|
||||
look_at_anim: true,
|
||||
look_at_speed: 1.0,
|
||||
up_axis: "z".into(),
|
||||
@@ -456,6 +465,8 @@ impl Default for Prefs {
|
||||
agent_chat_side: "right".into(),
|
||||
agent_chat_width: 380.0,
|
||||
agent_chat_minimized: false,
|
||||
agent_chat_popped: false,
|
||||
agent_chat_window: None,
|
||||
agent_chat_turn_limit: crate::agent_chat::gate::DEFAULT_TURN_LIMIT,
|
||||
}
|
||||
}
|
||||
@@ -615,6 +626,8 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
p.agent_chat_side = d.agent_chat_side;
|
||||
p.agent_chat_width = d.agent_chat_width;
|
||||
p.agent_chat_minimized = d.agent_chat_minimized;
|
||||
p.agent_chat_popped = d.agent_chat_popped;
|
||||
p.agent_chat_window = d.agent_chat_window;
|
||||
p.agent_chat_turn_limit = d.agent_chat_turn_limit;
|
||||
}
|
||||
1 => {
|
||||
@@ -625,6 +638,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
p.zoom_to_cursor = d.zoom_to_cursor;
|
||||
p.smooth_wheel_zoom = d.smooth_wheel_zoom;
|
||||
p.trackpad_pan = d.trackpad_pan;
|
||||
p.pan_reverse_x = d.pan_reverse_x;
|
||||
p.orbit_around = d.orbit_around;
|
||||
p.orbit_cursor = d.orbit_cursor;
|
||||
p.space_mouse_enabled = d.space_mouse_enabled;
|
||||
@@ -728,6 +742,11 @@ pub fn nav_mode(app: &SolveApp, button: NavButton, mods: Modifiers) -> Option<Na
|
||||
nav_preset(&app.preferences.nav).mode(button, mods)
|
||||
}
|
||||
|
||||
/// +1, or −1 when horizontal scrolling pans the other way (#2007).
|
||||
pub fn pan_sign_x(app: &SolveApp) -> f32 {
|
||||
if app.preferences.pan_reverse_x { -1.0 } else { 1.0 }
|
||||
}
|
||||
|
||||
/// +1, or −1 when the wheel zooms the other way.
|
||||
pub fn zoom_sign(app: &SolveApp) -> f64 {
|
||||
if app.preferences.zoom_reverse { -1.0 } else { 1.0 }
|
||||
@@ -748,32 +767,6 @@ pub(crate) fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2
|
||||
if b.is_empty() { app.cam.target } else { b.center() }
|
||||
}
|
||||
|
||||
/// Units per millimetre and the unit's name.
|
||||
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.
|
||||
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 })
|
||||
}
|
||||
|
||||
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 plain number typed into a field, with a decimal point or a decimal comma (#1347): "2.5"
|
||||
/// and "2,5" are both 2.5, as a numeric keypad types either depending on the locale. Spaces are
|
||||
/// ignored and "−" is a minus, as in egui's own parser. A comma beside a point ("1.234,5") or a
|
||||
@@ -808,12 +801,6 @@ pub fn parse_length_mm(text: &str, scale: f64) -> Option<f64> {
|
||||
value.is_finite().then_some(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))
|
||||
}
|
||||
|
||||
/// A length, area (`power` 2) or volume (3) given in millimetres, shown in the design's units
|
||||
/// with the length precision.
|
||||
pub fn show_mm(app: &SolveApp, v: f64, power: i32) -> String {
|
||||
@@ -1189,6 +1176,10 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
crate::i18n::tr_label("Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms")
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(crate::i18n::tr_label("Reverse horizontal pan direction"));
|
||||
ui.checkbox(&mut app.preferences.pan_reverse_x, "")
|
||||
.on_hover_text(crate::i18n::tr_label("Horizontal two-finger scrolling and the thumb wheel pan the other way"));
|
||||
ui.end_row();
|
||||
ui.label(crate::i18n::tr_label("Orbit around"));
|
||||
// Wrapped: three choices, and German or Russian labels are long.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
@@ -1869,6 +1860,18 @@ mod tests {
|
||||
assert_eq!(other.preferences.nav, OPENSCAD_NAV);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_reverse_x_defaults_off_persists_and_flips_the_sign() {
|
||||
assert!(!serde_json::from_value::<Prefs>(json!({})).unwrap().pan_reverse_x);
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
assert_eq!(pan_sign_x(&app), 1.0);
|
||||
set(&mut app, &json!({"pan_reverse_x": true})).unwrap();
|
||||
assert_eq!(pan_sign_x(&app), -1.0);
|
||||
let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
other.load_prefs(&app.prefs());
|
||||
assert!(other.preferences.pan_reverse_x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trackpad_pan_defaults_on_persists_and_follows_dialog_buttons() {
|
||||
assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().trackpad_pan);
|
||||
|
||||
@@ -220,7 +220,9 @@ fn live(app: &SolveApp, t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> {
|
||||
}
|
||||
("sketch.arc.center_point", 2) => {
|
||||
let a0 = (a - first).angle();
|
||||
vec![(((e - first).angle() - a0).rem_euclid(std::f64::consts::TAU), e)]
|
||||
// Out along the radius, clear of the cursor: a box on it would take the hover and
|
||||
// the click meant for a point there (#2201).
|
||||
vec![(((e - first).angle() - a0).rem_euclid(std::f64::consts::TAU), e + dir_or_x(e - first) * (2.5 * off))]
|
||||
}
|
||||
("sketch.conic", 2) => {
|
||||
// `rho` is the curve's fullness, not a distance: the conic passes through
|
||||
@@ -234,7 +236,9 @@ fn live(app: &SolveApp, t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> {
|
||||
}
|
||||
("sketch.slot.center_to_center" | "sketch.slot.overall", 2) => {
|
||||
let dir = dir_or_x(a - first);
|
||||
vec![(2.0 * dir.cross(e - first).abs(), e)]
|
||||
let side = if dir.cross(e - first) < 0.0 { -1.0 } else { 1.0 };
|
||||
// Beyond the cursor, away from the centre line, as for the arc (#2201).
|
||||
vec![(2.0 * dir.cross(e - first).abs(), e + dir.perp() * (side * 2.5 * off))]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
@@ -402,6 +406,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
|
||||
}
|
||||
let mut completed = false;
|
||||
let mut any_focused = false;
|
||||
let view = app.viewport.layout.as_ref().map(|l| l.drawable).filter(|r| r.is_finite() && r.is_positive());
|
||||
for (i, b) in t.dims.iter_mut().enumerate() {
|
||||
let Some((v, at)) = lv.get(i).copied() else { continue };
|
||||
if !b.locked {
|
||||
@@ -417,7 +422,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
|
||||
s.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
|
||||
s.store(ui.ctx(), id);
|
||||
}
|
||||
let pos: Pos2 = sp + vec2(-34.0, -11.0);
|
||||
let pos = in_view(sp + vec2(-34.0, -11.0), view, ui.ctx().pointer_latest_pos());
|
||||
crate::frame::area(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let fill = if b.locked { tk.accent_soft } else { tk.panel };
|
||||
egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
|
||||
@@ -465,6 +470,28 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
|
||||
(enter && (any_focused || nothing)).then_some(e)
|
||||
}
|
||||
|
||||
/// About the size of a value box, for keeping it in view.
|
||||
const BOX: egui::Vec2 = egui::vec2(76.0, 24.0);
|
||||
|
||||
/// Where a value box whose top left would be `pos` goes so it stays in the drawing area `view`:
|
||||
/// as it is when it fits; else mirrored through the pointer (a box set off the cursor, past the
|
||||
/// view's edge, goes to the cursor's other side, still clear of it, #2201); else pushed inside.
|
||||
fn in_view(pos: Pos2, view: Option<egui::Rect>, pointer: Option<Pos2>) -> Pos2 {
|
||||
let Some(view) = view else { return pos };
|
||||
let fits = |p: Pos2| view.contains_rect(egui::Rect::from_min_size(p, BOX));
|
||||
if fits(pos) {
|
||||
return pos;
|
||||
}
|
||||
if let Some(c) = pointer {
|
||||
let mirrored = c + (c - (pos + BOX * 0.5)) - BOX * 0.5;
|
||||
if fits(mirrored) {
|
||||
return mirrored;
|
||||
}
|
||||
}
|
||||
let max = (view.max - BOX).max(view.min);
|
||||
pos.clamp(view.min, max)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_dims_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{BOX, in_view};
|
||||
use crate::scenario::Harness;
|
||||
|
||||
fn drawing(command: &str) -> Harness {
|
||||
@@ -316,3 +317,25 @@ fn a_line_end_on_a_circle_outside_the_axis_snap_stays_at_the_cursor() {
|
||||
let end = sk.point(b).unwrap();
|
||||
assert!((end.len() - 30.0).abs() < 1e-9 && end.dist(at) < 0.5, "{end:?}");
|
||||
}
|
||||
|
||||
/// A value box set off the cursor stays in the drawing area (#2201): past an edge it goes to the
|
||||
/// cursor's other side, still clear of it; with no room either side it is pushed inside.
|
||||
#[test]
|
||||
fn value_boxes_stay_in_view_and_off_the_cursor() {
|
||||
let view = egui::Rect::from_min_max(egui::pos2(250.0, 130.0), egui::pos2(1650.0, 960.0));
|
||||
let cursor = egui::pos2(565.0, 142.0);
|
||||
// 50 px above the cursor: over the ribbon.
|
||||
let above = cursor + egui::vec2(-34.0, -11.0 - 50.0);
|
||||
let p = in_view(above, Some(view), Some(cursor));
|
||||
let rect = egui::Rect::from_min_size(p, BOX);
|
||||
assert!(view.contains_rect(rect), "{rect:?} in {view:?}");
|
||||
assert!(!rect.expand(2.0).contains(cursor), "{rect:?} clear of {cursor:?}");
|
||||
assert!(rect.min.y > cursor.y, "below the cursor now");
|
||||
// A box that fits stays where it is; without a view nothing moves.
|
||||
let inside = egui::pos2(700.0, 500.0);
|
||||
assert_eq!(in_view(inside, Some(view), Some(cursor)), inside);
|
||||
assert_eq!(in_view(above, None, Some(cursor)), above);
|
||||
// No room on either side: inside the view all the same.
|
||||
let tiny = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(80.0, 30.0));
|
||||
assert!(tiny.contains_rect(egui::Rect::from_min_size(in_view(egui::pos2(-200.0, -200.0), Some(tiny), Some(egui::pos2(40.0, 15.0))), BOX)));
|
||||
}
|
||||
@@ -182,3 +182,50 @@ fn typed_drawing_preserves_construction_and_centerline_modes_at_undo_limit() {
|
||||
crate::sketch_palette::set_mode(&mut h.app, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The third pick of a Center Point Arc reaches existing geometry under the pointer: the value
|
||||
/// box (the arc's sweep, a slot's width) stays clear of the cursor instead of sitting on it
|
||||
/// (#2201).
|
||||
#[test]
|
||||
fn the_arc_end_pick_snaps_to_a_point_under_the_value_box() {
|
||||
for (command, picks, end) in [
|
||||
("sketch.arc.center_point", [[0.0, 0.0], [10.0, 0.0]], [0.0, 10.0]),
|
||||
("sketch.slot.center_to_center", [[-10.0, 0.0], [10.0, 0.0]], [0.0, 10.0]),
|
||||
] {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0, 0], end], "infer": false})).unwrap();
|
||||
h.app.run("sketch.construction", json!({"curves":["l1"]})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.app.cam.distance = 60.0;
|
||||
h.app.cam.target = solidcraft_engine::geom::Vec3::new(0.0, 5.0, 0.0);
|
||||
h.settle();
|
||||
h.step(&json!({"start":command})).unwrap();
|
||||
for p in picks {
|
||||
h.step(&json!({"click":{"world":[p[0], p[1], 0]}})).unwrap();
|
||||
}
|
||||
h.step(&json!({"move":{"world":[end[0], end[1], 0]}})).unwrap();
|
||||
let cursor = h.at(&json!({"world":[end[0], end[1], 0]})).unwrap();
|
||||
let cursor = egui::pos2(cursor[0] as f32, cursor[1] as f32);
|
||||
let blocking = h.ctx.memory(|m| m.area_rect(egui::Id::new(("sc_dim_area", 0usize)))).filter(|r| r.expand(2.0).contains(cursor));
|
||||
assert!(blocking.is_none(), "{command}: the value box {blocking:?} sits under the cursor {cursor:?}");
|
||||
h.step(&json!({"click":{"world":[end[0], end[1], 0]}})).unwrap();
|
||||
// A slot's third pick sets its width only; the arc's end takes the point it lands on.
|
||||
if command != "sketch.arc.center_point" {
|
||||
continue;
|
||||
}
|
||||
let state = h.app.session.world_state();
|
||||
let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
let line_end = sketch.curves.iter().find(|c| c.id == "l1").and_then(|c| match c.kind {
|
||||
CurveKind::Line { b, .. } => Some(b),
|
||||
_ => None,
|
||||
});
|
||||
let snapped =
|
||||
sketch.constraints.iter().any(|c| matches!(&c.kind, ConstraintKind::Coincident { p, q } if Some(*p) == line_end || Some(*q) == line_end))
|
||||
|| sketch.curves.iter().filter(|c| c.id != "l1").any(|c| match c.kind {
|
||||
CurveKind::Arc { a, b, .. } => Some(a) == line_end || Some(b) == line_end,
|
||||
_ => false,
|
||||
});
|
||||
assert!(snapped, "{command}: the third pick snapped to the line's end: {:?}", sketch.constraints.iter().map(|c| &c.kind).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,39 @@ pub(crate) use crate::icons::glyphs::transport_glyph;
|
||||
use egui::{Rect, Sense, Stroke, UiBuilder, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::doc::{
|
||||
FeatureKind, PlaneRef,
|
||||
FeatureKind, PlaneRef, SketchId,
|
||||
construct::{AxisDef, PointDef},
|
||||
};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{SolveApp, icons};
|
||||
|
||||
// Snapshot only the data used by timeline interactions. Cloning FeatureKind here would
|
||||
// also copy imported mesh vertices and triangles on every frame.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum TimelineKind {
|
||||
Sketch,
|
||||
Profile(SketchId),
|
||||
Other,
|
||||
}
|
||||
|
||||
impl TimelineKind {
|
||||
pub(crate) fn of(kind: &FeatureKind) -> Self {
|
||||
match kind {
|
||||
FeatureKind::Sketch { .. } => Self::Sketch,
|
||||
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => Self::Profile(*sketch),
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn profile_sketch(self) -> Option<SketchId> {
|
||||
match self {
|
||||
Self::Profile(sketch) => Some(sketch),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
match k {
|
||||
FeatureKind::Sketch { .. } => "sketch",
|
||||
@@ -25,7 +51,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
FeatureKind::SurfaceRuled { .. } => "surface_ruled",
|
||||
FeatureKind::SurfaceUntrim { .. } => "surface_untrim",
|
||||
FeatureKind::SurfaceBlend { .. } => "surface_blend",
|
||||
FeatureKind::SurfaceBoundary { .. } => "surface_boundary",
|
||||
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "surface_boundary",
|
||||
FeatureKind::SurfaceMatch { .. } => "continuity",
|
||||
FeatureKind::SurfaceFair { .. } => "surface_fair",
|
||||
FeatureKind::Revolve { .. } => "revolve",
|
||||
@@ -263,7 +289,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), f.kind.clone()))
|
||||
.map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), TimelineKind::of(&f.kind)))
|
||||
.collect();
|
||||
// The feature whose body is under the cursor in the viewport.
|
||||
let st = app.session.world_state();
|
||||
@@ -373,12 +399,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if resp.double_clicked() {
|
||||
edit = Some(*id);
|
||||
}
|
||||
let sketch_of = match kind {
|
||||
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => {
|
||||
Some(*sketch)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let sketch_of = kind.profile_sketch();
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button(crate::i18n::tr_label("Edit Feature")).clicked() {
|
||||
edit = Some(*id);
|
||||
@@ -549,6 +570,7 @@ mod tests {
|
||||
("surface.reverse_normal", json!({"type":"reverse_normal", "body":"A"})),
|
||||
("surface.blend", json!({"type":"surface_blend", "from":{"body":"A", "edges":[]}, "to":{"body":"B", "edges":[]}})),
|
||||
("surface.boundary", json!({"type":"surface_boundary", "sketch":0, "curves":[]})),
|
||||
("surface.boundary", json!({"type":"surface_network", "u":[]})),
|
||||
("surface.match", json!({"type":"surface_match", "body":"A", "target":"B", "edges":[]})),
|
||||
("surface.fair", json!({"type":"surface_fair", "body":"A", "tolerance":"0.001"})),
|
||||
("solid.rib", json!({"type":"rib", "sketch":0, "curves":[], "thickness":"2"})),
|
||||
@@ -595,6 +617,27 @@ mod tests {
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_feature_menu_edits_its_profile_sketch_in_both_timelines() {
|
||||
for left in [false, true] {
|
||||
let mut h = Harness::new();
|
||||
h.app.preferences.timeline_left = left;
|
||||
h.app.run("sketch.create", json!({"plane": "XY", "name": "Base"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("solid.extrude", json!({"sketch": "Base", "distance": 10, "operation": "new", "name": "Depth"})).unwrap();
|
||||
let sketch = h.app.session.doc.find_feature("Base").map(|f| f.id).unwrap();
|
||||
h.frames(4);
|
||||
|
||||
h.step(&json!({"click": {"handle": "timeline:Base"}, "button": "right"})).unwrap();
|
||||
assert!(h.widget("Edit Profile Sketch").is_none(), "left: {left}");
|
||||
h.step(&json!({"key": "Escape"})).unwrap();
|
||||
h.step(&json!({"click": {"handle": "timeline:Depth"}, "button": "right"})).unwrap();
|
||||
h.step(&json!({"widget": "Edit Profile Sketch"})).unwrap();
|
||||
assert_eq!(h.app.session.active_sketch, Some(sketch), "left: {left}");
|
||||
}
|
||||
}
|
||||
|
||||
fn copied(h: &Harness) -> Vec<String> {
|
||||
h.app.session.clipboard.iter().map(|f| f.name.clone()).collect()
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
use egui::{Color32, Rect, RichText, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::doc::FeatureKind;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::timeline::{clipboard_buttons, delete_feature, delete_features, icon_of, message_button, selected_feature_ids, transport_glyph};
|
||||
use crate::timeline::{
|
||||
TimelineKind, clipboard_buttons, delete_feature, delete_features, icon_of, message_button, selected_feature_ids, transport_glyph,
|
||||
};
|
||||
use crate::{SolveApp, icons};
|
||||
|
||||
const FOLDER_INDENT: f32 = 14.0;
|
||||
@@ -427,7 +428,7 @@ fn group_ancestors_are_open(app: &SolveApp, groups: &[solidcraft_engine::doc::Ti
|
||||
|
||||
fn group_first_feature(
|
||||
groups: &[solidcraft_engine::doc::TimelineGroup],
|
||||
features: &[(u64, String, &'static str, bool, &'static str, FeatureKind)],
|
||||
features: &[(u64, String, &'static str, bool, &'static str, TimelineKind)],
|
||||
id: u64,
|
||||
) -> Option<usize> {
|
||||
let group = groups.iter().find(|group| group.id == id)?;
|
||||
@@ -438,7 +439,7 @@ fn group_first_feature(
|
||||
|
||||
fn group_last_feature(
|
||||
groups: &[solidcraft_engine::doc::TimelineGroup],
|
||||
features: &[(u64, String, &'static str, bool, &'static str, FeatureKind)],
|
||||
features: &[(u64, String, &'static str, bool, &'static str, TimelineKind)],
|
||||
id: u64,
|
||||
) -> Option<usize> {
|
||||
let group = groups.iter().find(|group| group.id == id)?;
|
||||
@@ -449,7 +450,7 @@ fn group_last_feature(
|
||||
|
||||
fn visible_order(
|
||||
app: &SolveApp,
|
||||
features: &[(u64, String, &'static str, bool, &'static str, FeatureKind)],
|
||||
features: &[(u64, String, &'static str, bool, &'static str, TimelineKind)],
|
||||
groups: &[solidcraft_engine::doc::TimelineGroup],
|
||||
) -> Vec<TimelineItem> {
|
||||
let mut order = Vec::new();
|
||||
@@ -615,7 +616,9 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.map(|feature| (feature.id, feature.name.clone(), icon_of(&feature.kind), feature.suppressed, feature.kind.type_name(), feature.kind.clone()))
|
||||
.map(|feature| {
|
||||
(feature.id, feature.name.clone(), icon_of(&feature.kind), feature.suppressed, feature.kind.type_name(), TimelineKind::of(&feature.kind))
|
||||
})
|
||||
.collect();
|
||||
let groups = app.session.doc.timeline_groups.clone();
|
||||
let order = visible_order(app, &features, &groups);
|
||||
@@ -685,7 +688,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
slots.push(VerticalSlot { y: ui.cursor().top(), position: index, group: group_id, indent_group });
|
||||
let indent = group_id.map(|id| (group_depth(&groups, id) + 1) as f32 * 14.0).unwrap_or(0.0);
|
||||
let has_patterns = matches!(kind, FeatureKind::Sketch { .. }) && crate::sketch_pattern_tree::has_patterns(app, *id);
|
||||
let has_patterns = matches!(kind, TimelineKind::Sketch) && crate::sketch_pattern_tree::has_patterns(app, *id);
|
||||
let pattern_key = format!("timeline/sketch/{id}/patterns");
|
||||
let patterns_open = crate::browser::is_open(app, &pattern_key, true);
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag());
|
||||
@@ -771,10 +774,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if response.double_clicked() && !on_fold {
|
||||
edit = Some(*id);
|
||||
}
|
||||
let sketch = match kind {
|
||||
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => Some(*sketch),
|
||||
_ => None,
|
||||
};
|
||||
let sketch = kind.profile_sketch();
|
||||
if response.secondary_clicked() && !selected {
|
||||
select_only(app, TimelineItem::Feature(*id));
|
||||
}
|
||||
|
||||
@@ -4718,6 +4718,90 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn device_face_app(gpu: bool) -> SolveApp {
|
||||
let mut app = hover_app(gpu);
|
||||
app.session = solidcraft_engine::Session::default();
|
||||
app.session.execute("solid.box", &json!({"length":40,"width":40,"height":40,"corner":[-20,-20,-20]})).unwrap();
|
||||
app.ui.show_origin = false;
|
||||
app.cam.target = Vec3::ZERO;
|
||||
app.cam.distance = 110.0;
|
||||
app.cam.yaw = 0.5;
|
||||
app.cam.pitch = 0.5;
|
||||
app
|
||||
}
|
||||
|
||||
fn device_hover_frame(app: &mut SolveApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||||
use crate::space_mouse::{DeviceMotion, Motion};
|
||||
let before = app.cam;
|
||||
app.viewport.device_motion = Some(DeviceMotion { motion: Motion { translation: [0.0; 3], rotation: [0.0, 0.0, 0.1] }, seconds: 0.016 });
|
||||
hover_frame(app, ctx, events);
|
||||
assert_ne!(app.cam, before, "the device must keep moving the camera");
|
||||
assert!(app.viewport.device_motion.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_navigation_keeps_face_hover_current_for_a_stationary_or_moving_pointer() {
|
||||
for gpu in [false, true] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = device_face_app(gpu);
|
||||
hover_frame(&mut app, &ctx, vec![]);
|
||||
let p = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||||
for offset in [0.0, 15.0, -15.0, 0.0] {
|
||||
let at = p + vec2(offset, 0.0);
|
||||
let mut key = app.viewport.last_pick.as_ref().unwrap().0;
|
||||
for events in [vec![egui::Event::PointerMoved(at)], vec![], vec![]] {
|
||||
device_hover_frame(&mut app, &ctx, events);
|
||||
let proj = projection(&app, app.viewport.rect.unwrap());
|
||||
let (expected, _) = candidate(&app, &pick(&app, &proj, at)).unwrap();
|
||||
assert!(matches!(expected, Hit::Face { .. }));
|
||||
assert_eq!(app.viewport.hover.as_ref(), Some(&expected), "hover must use the current camera and pointer");
|
||||
let next = app.viewport.last_pick.as_ref().unwrap().0;
|
||||
assert_ne!(next, key, "a moving camera must invalidate cached picking");
|
||||
key = next;
|
||||
}
|
||||
}
|
||||
hover_frame(&mut app, &ctx, vec![]);
|
||||
assert!(matches!(app.viewport.hover, Some(Hit::Face { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_navigation_allows_face_clicks_and_preserves_modifier_selection() {
|
||||
for gpu in [false, true] {
|
||||
for (modifiers, add) in [(egui::Modifiers::NONE, false), (egui::Modifiers::SHIFT, true), (egui::Modifiers::CTRL, true)] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = device_face_app(gpu);
|
||||
let existing = Sel::Body { name: "Body1".into() };
|
||||
app.session.selection = vec![existing.clone()];
|
||||
hover_frame(&mut app, &ctx, vec![]);
|
||||
let p = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap();
|
||||
device_hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p - vec2(15.0, 0.0))]);
|
||||
assert_eq!(app.session.selection, vec![existing.clone()], "device motion alone must retain selection");
|
||||
let at = p + vec2(15.0, 0.0);
|
||||
device_hover_frame(
|
||||
&mut app,
|
||||
&ctx,
|
||||
vec![
|
||||
egui::Event::ModifiersChanged(modifiers),
|
||||
egui::Event::PointerMoved(at),
|
||||
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers },
|
||||
],
|
||||
);
|
||||
device_hover_frame(
|
||||
&mut app,
|
||||
&ctx,
|
||||
vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers }],
|
||||
);
|
||||
let proj = projection(&app, app.viewport.rect.unwrap());
|
||||
let (_, selected) = candidate(&app, &pick(&app, &proj, at)).unwrap();
|
||||
assert!(matches!(selected, Sel::Face { .. }));
|
||||
let expected = if add { vec![existing, selected] } else { vec![selected] };
|
||||
assert_eq!(app.session.selection, expected, "the click must select the face under the current camera");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-handed use: while a 3D mouse turns the view, the pointer still preselects what is
|
||||
/// under it in the view as it is now, and a click picks it (it neither does nothing nor
|
||||
/// clears the selection).
|
||||
|
||||
@@ -36,6 +36,12 @@ when you send a message or press **Connect**.
|
||||
window and let go. The side it would land on lights up while you drag. You can also pick
|
||||
**Left** or **Right** under Preferences → AI → Panel. SolidCraft remembers the side.
|
||||
- **Width:** drag the panel's inner edge. SolidCraft remembers it.
|
||||
- **Its own window:** the pop-out button in the header (a window with an arrow) moves the chat
|
||||
into a window of its own, named after the design, which you can move anywhere, including
|
||||
another screen. The window's own close button closes the chat, which opens there again next
|
||||
time; **Dock** (the same place in its header) puts it back in the side panel. You can also
|
||||
pick **Own window** under Preferences → AI → Panel. SolidCraft remembers where the window
|
||||
was and how big.
|
||||
- **Minimise** (the line in the header) folds the panel into a thin strip that keeps the chat
|
||||
icon, the agent's state, and a count of anything waiting for your answer. Click the icon to
|
||||
unfold it. **Close** (×) hides the panel. Neither stops the agent; **Stop** does.
|
||||
|
||||
@@ -24,7 +24,11 @@ spline and conic sides the slope is approximate: the moved outline is a curve of
|
||||
within 2 % of the taper's offset (or 10 µm) of the true one, a curve that turns too much for
|
||||
that being cut into pieces first; where a curve bends tighter than the offset, the offset folds
|
||||
and is smoothed over. A taper steeper than the profile's details (a stroke, a curve or a gap
|
||||
narrower than it) is refused with that reason; a smaller taper or distance builds.
|
||||
narrower than it) is refused with that reason; a smaller taper or distance builds. A taper
|
||||
composes with a start offset (a drafted pocket that starts part way up a part): the offset moves
|
||||
the profile along the normal and the sides are drafted from there, so the profile is the section
|
||||
at the start whatever the offset. A taper with a two-sided or symmetric extent is not built yet
|
||||
and says so.
|
||||
|
||||
**Thin Extrude** (`thin: true` on `solid.extrude`, the Thin Extrude box in its dialog) builds a
|
||||
wall of constant thickness along sketch curves instead of filling profiles: open chains (a single
|
||||
|
||||
@@ -152,11 +152,11 @@ labels; `prefs:measure_axis_distances` identifies the checkbox.
|
||||
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
|
||||
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
|
||||
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text |
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `show_frame_rate` (default false: frames per second in the view's bottom-right corner; the view redraws every frame while it is on), `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file, `stand_up: false` leaves it as it is ("Leave it": the offer closes and `offer_stand_up` stays on); the offer's buttons are the handles `stand_up:stand`, `stand_up:leave` and `stand_up:never` | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `pan_reverse_x` (default false: horizontal scrolling pans the other way), `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `show_frame_rate` (default false: frames per second in the view's bottom-right corner; the view redraws every frame while it is on), `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file, `stand_up: false` leaves it as it is ("Leave it": the offer closes and `offer_stand_up` stays on); the offer's buttons are the handles `stand_up:stand`, `stand_up:leave` and `stand_up:never` | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
|
||||
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
|
||||
| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp` or `api`, `command` or `base_url` + `model`}; no keys), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `model?` (one of its models), `open?`, `side?` (`left`, `right`), `minimized?`, `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `send?`: text, `attach_view?`, `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `agent`, `modes`, `mode`, `models`, `model`, `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
|
||||
| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp` or `api`, `command` or `base_url` + `model`}; no keys), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `model?` (one of its models), `open?`, `side?` (`left`, `right`), `minimized?`, `popped?` (in its own window), `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `send?`: text, `attach_view?`, `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `popped`, `window` (its place and size), `agent`, `modes`, `mode`, `models`, `model`, `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
|
||||
| `ui.resize` | `width`, `height` | |
|
||||
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Drawing foundations: first geometry slice
|
||||
|
||||
This is the W00 bootstrap ledger for the [published Drawing plan][plan] in
|
||||
[#860][rfc]. The first implementation is W01a, [#2053][w01-issue] /
|
||||
[PR #2085][w01-pr], owned by `0xlillith`: neutral section-geometry records,
|
||||
structural validation and conversion to existing planar types. Its concrete API
|
||||
and limitations are in [the geometry contract](drawing-geometry.md).
|
||||
|
||||
W01a is part of W01, not completion of W00, the full W01 contract, Stage A, or the
|
||||
Drawing workspace. It does not extract a body section, calculate visibility,
|
||||
register `inspect.section_curves` or `drawing.view`, export a drawing, or save a
|
||||
Drawing document. The independent formatter slice W09a is tracked in
|
||||
[#2054][w09-issue] / [PR #2086][w09-pr]; it is not a module supplied by W01a.
|
||||
|
||||
## Scope, sources and status
|
||||
|
||||
The initial scope was checked against upstream `f7343e9161bc6243cdb076d3b6c3594db97279ff`.
|
||||
Recheck the implementation revision, current claims, [AGENTS](../AGENTS.md) and
|
||||
[CONTRIBUTING](../CONTRIBUTING.md) before taking the next slice. Local `plan/`
|
||||
documents were unavailable when this work began; this file does not reconstruct
|
||||
them or replace project instructions.
|
||||
|
||||
The published plan reconciles the [original RFC][rfc],
|
||||
[SychPL's investigation][visibility], [NOWN-DEV's two-stage proposal][fabrication]
|
||||
and CamWight's [study and questionnaire][study-comment]. The [attachment][study]
|
||||
retains the detailed requirements, alternatives and sources. Product research is
|
||||
requirements evidence; its descriptions and proposed standards rules are not
|
||||
implementation code, independently verified conformance or permission to copy
|
||||
protected content.
|
||||
|
||||
Here, **W01a** means code in this slice; **future** means a requirement still to
|
||||
implement or decide. A merged representation or validator does not establish a
|
||||
geometry producer's capability. Drawing milestones in the RFC are distinct from
|
||||
the repository roadmap's M0-M14; the Drawing workspace remains roadmap M11.
|
||||
|
||||
## First-section requirements and fixtures
|
||||
|
||||
Fixture IDs below identify checks, not saved designs or registered commands.
|
||||
The current fixture details and future producer gates are in
|
||||
[the geometry contract](drawing-geometry.md#fixtures-and-handoff).
|
||||
|
||||
| ID | Requirement and source | Package | Fixture / status |
|
||||
| --- | --- | --- | --- |
|
||||
| S01 | Model mm/radians, explicit plane axes and correct XZ sign; plan §4.1/W01 | W01a | F01: plane validation and coordinates; W01a |
|
||||
| S02 | Reuse supported analytic/conic/Bézier representations without silently fitting them; plan §4.1/W01 | W01a, then W02-W03 | F02: representation/serde checks; W01a. Producing exact sections is future |
|
||||
| S03 | Keep accuracy categories explicit; plan §4.1/W01 | W01a, then W02-W03 | F02/F04: analytic/bounded/sampled metadata and finite values; W01a. Evidence for producer bounds is future |
|
||||
| S04 | Indexed curves, oriented wire uses and region references; plan W01 and study §§10-12 | W01a | F03: manually constructed rectangle with a hole, orientation-preserving conversion; W01a |
|
||||
| S05 | Certify closed section boundaries, winding, nesting, material regions and areas; plan W03 | W03 | F05/F06: body-derived hole/oblique-cylinder sections; future. PR #2068 proposes producer tests for review. Structural validation alone does not meet this requirement |
|
||||
| S06 | Empty, tangent, open, coincident, degenerate and unsupported cuts are distinguishable; plan W03 | W01 follow-up, W02-W03 | F07: producer outcomes; future. Current empty vectors/contact points are only representable data |
|
||||
| S07 | Complete source/body/occurrence identity, provenance and freshness; plan §4.1/W01, NOWN-DEV Stage A | W01 follow-up, W02-W04 | F08: repeated placements and failure in one selected body; future. Current indices are result-local |
|
||||
| S08 | Bound input/work and fail without mutation or partial success; project rules, plan W01-W04 | W01a, then W02-W04 | F04: structural bounds; W01a. Byte/allocation limits, producer work budgets and cancellation are future caller responsibilities |
|
||||
| S09 | Standalone read-only `inspect.section_curves`, existing plane forms, explicit selection and no sketch/viewport prerequisite; NOWN-DEV Stage A | W04 after W03 | F09: command/CLI/MCP parity, unchanged document/undo and error cases; future |
|
||||
| S10 | SVG/DXF with units, source groups/layers, declared format accuracy and atomic optional file output; NOWN-DEV Stage A | W04, W10 geometry subset | F09: physical coordinates and complete output; future |
|
||||
| S11 | One section provider shared with the visibility investigation; SychPL/NOWN-DEV and plan W03/W05 | W02-W06 | F05-F08 and a reviewed capability matrix; future |
|
||||
| S12 | Transient dimensioned snapshots remain distinct from associative drawings; NOWN-DEV Stage B | W07-W08 | Future snapshot/measurement/output fixtures after their contracts settle |
|
||||
|
||||
The remaining industrial requirements are retained in the published plan's
|
||||
coverage ledger and W09-W47: formatting, persistent sheets/references, dimensions,
|
||||
BOM/balloons, standards, derived views, production output and specialist domains.
|
||||
Expanding this bootstrap into atomic traceability for all 48 packages is remaining
|
||||
W00 work; absence from this first-section table does not remove a requirement.
|
||||
|
||||
## Dependencies, ownership and review
|
||||
|
||||
Claim links below identify the bounded slices already tracked here. Other rows do
|
||||
not reserve work or assign the contributors who proposed it. Check live issues and
|
||||
draft PRs before claiming. Review columns name the expertise needed, not reviewers
|
||||
who have already agreed to an assignment.
|
||||
|
||||
| Package | Depends on | Ownership / scope here | Review and next handoff |
|
||||
| --- | --- | --- | --- |
|
||||
| W00 | RFC and project rules | Bootstrap in W01a; full ledger/decision ownership remains open | Integration review: preserve sources, unresolved decisions and child-issue boundaries |
|
||||
| W01 | W00 immediate scope | `0xlillith`, [#2053][w01-issue]/[#2085][w01-pr], W01a only | Geometry/kernel and engine review: validate representation limits; finish source/provenance/outcome contracts before producer handoff |
|
||||
| W02 | Reviewed W01 producer contract and backend placement | Review overlap with NOWN-DEV's [#2068][section-pr] before another claim | Kernel/solidcore review: guarded live-body adapter, supported surfaces, complete source identity and no cache-byte authority |
|
||||
| W03 | W02 | Review/reuse producer proposal [#2068][section-pr]; exact supported subset remains to be agreed | Geometry/document review: analytic cuts, classified outcomes, winding/nesting/material checks and error evidence |
|
||||
| W04 | W03 and W10 geometry-only subset | NOWN-DEV's [#2068][section-pr] proposes Stage A command/output; not merged at the recorded review | Engine/IO review: explicit selection, request limits, no mutation, physical SVG/DXF, DTO adaptation and complete-or-error output |
|
||||
| W05 | Reviewed W01; W02 where extraction is needed | Coordinate with [SychPL's offer][visibility]; this is not an assignment | Geometry/kernel review: bounded HLR investigation and findings in the RFC before a substantial backend proposal |
|
||||
| W06 | Reviewed W05 findings and W02 | Future projection/visibility slice | Geometry/document review: supported scene-wide visibility, split intervals, provenance and cache keys |
|
||||
| W07 | W01, W09; W03 for cuts; an agreed preview adapter or W06 | Future transient `drawing.view` | Engine/presentation review: meaningful measurements, snapshot status and explicit approximation |
|
||||
| W08 | W07 and W10 text/dimension subset; W04 adapters as reused | Future preview and SVG/DXF/PNG slice | UI/IO review: shared navigation/layout, output scale and UI/CLI/MCP parity |
|
||||
|
||||
Stage A does not depend on persistent Drawing storage, PDF, fonts, a drafting
|
||||
profile or the W09 formatter. W10's geometry-only primitives are a separate
|
||||
increment from its text/dimension layout. W09a's [independent PR][w09-pr] can advance
|
||||
in parallel without introducing a dependency into W01a.
|
||||
|
||||
NOWN-DEV's [PR #2068][section-pr] is an overlapping Stage A producer/export
|
||||
proposal to review before further W02-W04 claims. It was open and ready, not
|
||||
merged, when inspected at head `4e945d8af594b8cc252cc4e5ffede3684b7bf3bd` on
|
||||
2026-10-17. It proposes `doc/project_section.rs` and the
|
||||
`inspect.section_curves` engine command with mesh/fitted loops and SVG/DXF.
|
||||
Its body-name/loop JSON and `sweep_deg` angles do not imply compatibility with
|
||||
W01a's radians, accuracy records and indexed regions. Agree an explicit adapter,
|
||||
accuracy and source/provenance contract before integration; preserve that
|
||||
contributor's claim and branch. Its tilted-cylinder test is proposed producer
|
||||
evidence to review, not an implemented W01a or current-main capability.
|
||||
|
||||
## Decision ledger
|
||||
|
||||
An **implemented slice choice** below describes W01a, not approval of every
|
||||
proposal in the RFC. Record later decisions with their evidence, reviewer, date,
|
||||
affected contract revision and dependent child issues.
|
||||
|
||||
| Decision | Current disposition | Next owner/review and gate |
|
||||
| --- | --- | --- |
|
||||
| D01: representation placement | W01a uses existing `geom::Plane`, `Seg2`, `Loop2` and `Region2`; neutral section records live at L0 | W01a geometry review. This does not decide the section/HLR algorithm's kernel placement |
|
||||
| D02: identity/status scope | W01a has only indices within one value and per-curve accuracy metadata; no persistent source names, provenance or freshness record | W01 follow-up with kernel/document/engine reviewers before W02-W04 depend on source contracts |
|
||||
| D03: validity claim | W01a validates structure and preserves supplied orientation. It does not certify winding, nesting, self-intersections or a material section | W03 owns producer/geometric validity evidence; callers must not promote structure checks into manufacturing certification |
|
||||
| D04: shared producer/backend | One reusable section provider is the plan; guarded extraction and substantial HLR placement must be coordinated with [solidcore][solidcore] | Kernel/solidcore and integration review before W02/W05 expand into a substantial backend |
|
||||
| D05: supported geometry/accuracy | Representation support is documented; producer surface/curve coverage and defensible approximation bounds remain future | W02-W03 publish per-case capabilities and F05-F08 results; W05 does the same for visibility |
|
||||
| D06: public commands | [#2068][section-pr] proposes `inspect.section_curves`; transient `drawing.view` remains future. W01a registers neither | Review #2068's distinct output schema before W04 integration; live registry/claim check in W04/W07; preserve existing viewport commands |
|
||||
| D07: later Drawing choices | Storage/undo, PDF/fonts, standards/profile semantics and release rules remain in the RFC decision register | Their relevant later packages decide them; none blocks this representation slice or standalone section work |
|
||||
|
||||
## Completion and handoff
|
||||
|
||||
Each subsequent child issue should state supported cases, owned paths, contract
|
||||
revision, exact dependencies, analytic expectations, error behavior and required
|
||||
cross-lane review. Follow current draft-claim, isolation and quality-gate rules;
|
||||
use `Refs #860` and close the bounded child issue rather than the umbrella RFC.
|
||||
|
||||
Report what was actually tested at the proposed merge revision. A structure-only
|
||||
API has no new CAD result to photograph; later producer, command and UI changes
|
||||
need real app evidence when their effect can be shown. Keep generated reference
|
||||
documentation, assets and native-format changes outside this slice. Root project
|
||||
instructions govern any later change to those areas.
|
||||
|
||||
[rfc]: https://github.com/tangency-labs/solidcraft/issues/860
|
||||
[plan]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6234779815
|
||||
[visibility]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6167030912
|
||||
[fabrication]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6231770565
|
||||
[study-comment]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6216908058
|
||||
[study]: https://github.com/user-attachments/files/33548772/inventor-drawings-research.md
|
||||
[w01-issue]: https://github.com/tangency-labs/solidcraft/issues/2053
|
||||
[w01-pr]: https://github.com/tangency-labs/solidcraft/pull/2085
|
||||
[w09-issue]: https://github.com/tangency-labs/solidcraft/issues/2054
|
||||
[w09-pr]: https://github.com/tangency-labs/solidcraft/pull/2086
|
||||
[section-pr]: https://github.com/tangency-labs/solidcraft/pull/2068
|
||||
[solidcore]: https://github.com/tangency-labs/solidcraft/issues/1597
|
||||
@@ -0,0 +1,253 @@
|
||||
# Neutral section geometry (W01a)
|
||||
|
||||
W01a, [#2053][issue] / [PR #2085][pr], adds the L0
|
||||
[`solidcraft_geom::section`][section-source] representation and structural validation. See the
|
||||
[foundation ledger](drawing-foundations.md) for dependencies and the remaining
|
||||
W01 work. This contract is deliberately smaller than the eventual section and
|
||||
projection result described in the [Drawing plan][plan].
|
||||
|
||||
The module can hold geometry supplied by a caller, validate its structure and
|
||||
convert selected closed wires/regions to existing `Loop2`/`Region2` values. It does
|
||||
not intersect model bodies, determine visibility, certify material regions,
|
||||
attach persistent references or register an engine command. No native design
|
||||
format, exporter, formatter or new runtime dependency is introduced here.
|
||||
|
||||
## Coordinates and frames
|
||||
|
||||
Distances and coordinates are millimetres; angular values in `Seg2` are radians.
|
||||
The model is Z-up. Each result contains the existing [`Plane`][plane-source]:
|
||||
|
||||
```text
|
||||
world(u, v) = origin + u * x + v * y
|
||||
normal = x cross y
|
||||
```
|
||||
|
||||
`validate_plane(&Plane)` checks a supplied frame without normalizing it. Origin
|
||||
and both axes must be finite, with every component's absolute value at most
|
||||
`1e12`. Each axis must be unit length within `1e-8`, and their absolute dot product
|
||||
must be at most `1e-8`. The normal is derived from the axes; no separate normal or
|
||||
handedness flag is serialized.
|
||||
|
||||
The existing named frames are:
|
||||
|
||||
| Plane | Local x | Local y | Derived normal |
|
||||
| --- | --- | --- | --- |
|
||||
| XY | +X | +Y | +Z |
|
||||
| XZ | +X | -Z | +Y |
|
||||
| YZ | +Y | +Z | +X |
|
||||
|
||||
In particular, XZ's local positive y points toward **negative world Z**. Replacing
|
||||
it with +Z would reverse the normal. `Plane::flipped` reverses an in-plane axis
|
||||
and therefore the derived normal; validation does not choose an orientation.
|
||||
|
||||
The future Stage A request adapter must preserve the existing
|
||||
[`inspect.section`][section-command] conventions: `offset` follows the named
|
||||
plane's normal; `at` is the world Z/Y/X coordinate for XY/XZ/YZ respectively;
|
||||
`offset` and `at` are mutually exclusive; `flip` changes the cut direction without
|
||||
moving the requested plane. These request fields are not part of W01a's DTO.
|
||||
|
||||
Mapping a point already on the plane to local coordinates and back is meaningful.
|
||||
Mapping an arbitrary 3D point to two coordinates loses its normal component; it
|
||||
does not have a unique 3D inverse.
|
||||
|
||||
## Data and serialization
|
||||
|
||||
| Type | Fields and meaning |
|
||||
| --- | --- |
|
||||
| `SectionGeometry` | `plane: Plane`, `curves: Vec<SectionCurve>`, `wires: Vec<SectionWire>`, `regions: Vec<SectionRegion>`, `contacts: Vec<Vec2>` |
|
||||
| `SectionCurve` | `geometry: Seg2`, `accuracy: CurveAccuracy` |
|
||||
| `SectionWire` | Ordered `curves: Vec<CurveUse>` and explicit `closed: bool` |
|
||||
| `CurveUse` | `curve: u32` indexes `SectionGeometry.curves`; `forward: bool` selects its direction of use |
|
||||
| `SectionRegion` | `outer: u32` indexes a wire; `holes: Vec<u32>` indexes its supplied hole wires |
|
||||
|
||||
All indices are **local to this one value**. They do not identify a model body,
|
||||
face, occurrence, revision or an entity across recomputation. W01a has no source
|
||||
fingerprint, provenance, freshness, persistent-ID or public schema-version field.
|
||||
Those require a reviewed producer/integration contract in a later W01 slice.
|
||||
|
||||
The new section DTOs reject unknown fields through serde. Existing `Plane`,
|
||||
`Vec2`, `Vec3` and [`Seg2`][segment-source] retain their existing serialization:
|
||||
vectors are numeric arrays; plane axes are `x_dir` and `y_dir`; segment variants
|
||||
use a `type` tag with snake-case names. Do not apply a blanket strict-schema claim
|
||||
to the reused nested types.
|
||||
|
||||
`Seg2` represents lines, circular arcs, cubic Béziers, rational quadratic conics
|
||||
and supported higher-degree polynomial Bézier pieces. This is not every curve in
|
||||
the neutral B-rep schema, nor evidence of an exact producer for each kind. An
|
||||
adapter must preserve supported geometry or report its actual conversion and
|
||||
accuracy; it must not silently coerce unsupported B-rep curves into this shape.
|
||||
|
||||
`CurveAccuracy` uses the internally tagged serde field `kind`:
|
||||
|
||||
| JSON form | Meaning supplied by the producer |
|
||||
| --- | --- |
|
||||
| `{"kind":"analytic"}` | The producer declares an analytic representation without fitting |
|
||||
| `{"kind":"bounded","max_deviation_mm":0.001}` | The producer claims a deviation bound in model millimetres |
|
||||
| `{"kind":"sampled","max_sample_deviation_mm":0.001}` | The recorded deviation applies to evaluated samples; it is not a bound between samples |
|
||||
|
||||
Validation checks the metadata's numeric domain. It does not independently prove
|
||||
an analytic classification or a claimed bound against a source surface. Accuracy
|
||||
is per curve, not a global `exact: true` flag. There is no implicit default tag.
|
||||
In particular, a requested fitting tolerance is not an established bound, and a
|
||||
zero sampled deviation remains sampled.
|
||||
|
||||
This complete value shows the actual serde spelling for one open line. It is
|
||||
data, not a request to an implemented Drawing command:
|
||||
|
||||
```json
|
||||
{
|
||||
"plane": {
|
||||
"origin": [0, 0, 0],
|
||||
"x_dir": [1, 0, 0],
|
||||
"y_dir": [0, 1, 0]
|
||||
},
|
||||
"curves": [
|
||||
{
|
||||
"geometry": {"type": "line", "a": [0, 0], "b": [10, 0]},
|
||||
"accuracy": {"kind": "analytic"}
|
||||
}
|
||||
],
|
||||
"wires": [
|
||||
{"curves": [{"curve": 0, "forward": true}], "closed": false}
|
||||
],
|
||||
"regions": [],
|
||||
"contacts": []
|
||||
}
|
||||
```
|
||||
|
||||
The value can pass structural validation. Converting wire 0 to a `Loop2` must
|
||||
fail because it is explicitly open. Merely meeting endpoints does not change a
|
||||
wire's `closed` flag. Contact points are representable, but W01a does not classify
|
||||
them as tangent intersections or certify their source.
|
||||
|
||||
## Validation and conversion boundary
|
||||
|
||||
The public operations are `validate_plane(&Plane)` and
|
||||
`SectionGeometry::validate_structure(tol)`, `to_loop(wire, tol)` and
|
||||
`to_region(region, tol)`. Errors use the module's `Invalid` error type. There is
|
||||
no geometric `bounds()` API or batch region-expansion API in this slice.
|
||||
|
||||
`tol` must be finite and in `1e-9..=1` mm. It is a structural endpoint-connectivity
|
||||
tolerance, separate from each curve's accuracy metadata and from future model
|
||||
intersection tolerances, paper deviation and dimension display precision.
|
||||
|
||||
The public `MAX_ITEMS` is `100_000`. Validation counts the aggregate of curves,
|
||||
wires, regions, contacts, all curve-use references and all hole references before
|
||||
walking the geometry. Counting references prevents a small curve set from hiding
|
||||
an unbounded number of uses. This limit is not a maximum JSON byte size.
|
||||
|
||||
Structural validation checks the frame, numeric domains, finite curve/contact
|
||||
data, bounded collection counts and local reference integrity. It checks all
|
||||
serialized Bézier control slots, including unused tail slots. Every wire must be
|
||||
nonempty; oriented consecutive endpoints must meet within `tol`. A wire declared
|
||||
closed must also meet last-to-first. Region boundaries must reference closed
|
||||
wires; a region cannot list its outer wire as a hole or repeat the same hole.
|
||||
Different regions may share a wire.
|
||||
|
||||
The arithmetic domains are explicit, not a geometric precision certificate:
|
||||
|
||||
| Stored value | Accepted domain |
|
||||
| --- | --- |
|
||||
| Coordinates and control slots | Finite; absolute component at most `1e12` mm |
|
||||
| Arc radius | `1e-12..=1e12` mm |
|
||||
| Arc start | `-1e6..=1e6` radians |
|
||||
| Absolute arc sweep | `1e-12..=2*pi + 1e-12` radians |
|
||||
| Conic weight | `1e-12..=1e12`; only positive weights are supported here |
|
||||
| Higher-degree Bézier `n` | `4..=9`, using the existing ten-slot control array |
|
||||
| Bounded/sample deviation | Finite, `0..=1e12` mm |
|
||||
|
||||
Line and Bézier/conic controls must not all coincide; evaluated segment endpoints
|
||||
must be finite. These tests do not certify a minimum physical feature size or a
|
||||
small absolute arithmetic error at extreme magnitudes. In particular, the stored
|
||||
coordinate limit is not a computed geometric bounding box.
|
||||
|
||||
`to_loop` and `to_region` revalidate the entire value, check the selected index and
|
||||
preflight the selected output's segment expansion before conversion. A
|
||||
`forward: false` use copies the reversed segment. They preserve the supplied wire
|
||||
orientation, outer/hole classification and geometry; they do not fit, repair,
|
||||
close or correct a boundary's winding to make it acceptable. Repeated conversions
|
||||
repeat validation. Callers planning many conversions must account for that work;
|
||||
this API does not provide a shared validation certificate or a batch result.
|
||||
|
||||
Conversion also checks the actual converted loop's closure with the same `tol`.
|
||||
Reversing an arc computes a new start angle; at large radii, floating-point
|
||||
rounding can make its evaluated endpoints disagree even when the original wire
|
||||
passed validation. The converter then returns
|
||||
`converted loop is not closed within tolerance`; it does not snap endpoints.
|
||||
`Loop2`/`Region2` contain geometry only, not `CurveAccuracy` or the result-local
|
||||
indices. A caller that needs those records must retain the original metadata;
|
||||
conversion neither carries an accuracy certificate nor promotes accuracy.
|
||||
|
||||
**Structural success does not certify winding, nesting, self-intersection,
|
||||
separation of regions or a physically valid material section.** Converters do not
|
||||
establish those properties either. The future section producer must supply and
|
||||
verify outer-CCW/hole-CW boundaries in `(x,y)` coordinates, nesting, signed/net area
|
||||
semantics and legitimate material regions. A result may be structurally valid
|
||||
while violating those future geometric requirements.
|
||||
|
||||
Existing profile area and containment methods also have their own curve-specific
|
||||
limitations; converting to `Loop2` or `Region2` does not make conic/freeform area or
|
||||
containment exact or certified.
|
||||
|
||||
Deserialization does not call structural validation automatically. Validate before
|
||||
use or serialization; the public data may have changed since an earlier check.
|
||||
Serde can allocate vectors before these validators run. A future file, command
|
||||
or transport adapter must impose byte, allocation, nesting and request limits
|
||||
before or during decoding, then call structural validation before downstream
|
||||
geometry operations. Producer traversal/iteration budgets, cancellation and
|
||||
complete source selection are also future caller responsibilities. None is
|
||||
provided by the `MAX_ITEMS` check after deserialization.
|
||||
|
||||
## Fixtures and handoff
|
||||
|
||||
These labels connect to the [requirement ledger](drawing-foundations.md).
|
||||
Current checks exercise manually supplied representation values, not extracted
|
||||
model geometry; see [`section_tests.rs`][tests-source]. Test results belong to the
|
||||
tested PR revision, not to this list.
|
||||
|
||||
| Fixture | Current check or remaining acceptance gate |
|
||||
| --- | --- |
|
||||
| F01: frames | W01a: finite/orthonormal plane validation, XZ sign and plane-coordinate mapping. Degenerate, nonfinite and out-of-range frames fail |
|
||||
| F02: representation/accuracy | W01a: supported segments and actual serde shape; accuracy numeric-domain checks; sampled error remains distinct from a bound |
|
||||
| F03: rectangle with a hole | W01a: manual `100 x 60 mm` rectangle and radius-5 circular hole at `(50,30)`, in `Plane::XY.offset(10)`. Conversion preserves signed areas `6000` and `-25*pi`, and net area `6000 - 25*pi mm²`. It is not a body-section or nesting certificate |
|
||||
| F04: malformed structure/conversion | W01a: invalid indices, disconnected/empty wires, closure, invalid region references, excessive aggregate items, nonfinite data, malformed curves and post-conversion endpoint agreement; no mutation |
|
||||
| F05: plate through a hole | W03: a `100 x 60 x 20 mm` box with a radius-5 through hole parallel to Z, cut by an XY plane strictly between its end faces, has section area `6000 - 25*pi mm²`; producer must identify and certify outer/hole boundaries |
|
||||
| F06: tilted cylinder | W03: radius 10, axis Z, plane normal 30° from Z, away from caps: ellipse semiaxes `10` and `10/cos(30°)`, area `100*pi/cos(30°)`. Analytic representation or justified conversion evidence is required |
|
||||
| F07: section outcomes | W03: empty/tangent/coincident/open/degenerate/unsupported cases, with explicit outcome semantics rather than a guessed empty success |
|
||||
| F08: source completeness | W02-W04: repeated/nested/mirrored occurrences retain source identity; one unsupported selected body cannot silently disappear from a complete result |
|
||||
| F09: command/output | W04: explicit plane/selection, same CLI/MCP/engine result, no model/undo mutation, complete physical SVG/DXF output and bounded cancellation/error behavior |
|
||||
|
||||
F03 is `line_arc_region_conversion_preserves_winding_and_analytic_area`: one
|
||||
rectangle edge is stored backwards and used in reverse; two semicircular arcs
|
||||
are used in reverse order/direction to form the hole. It checks orientation and
|
||||
unchanged input as well as these analytic line/arc areas. In contrast,
|
||||
`connected_bow_tie_and_wrong_winding_are_not_certified_or_silently_repaired`
|
||||
deliberately accepts and preserves structurally valid but geometrically unsuitable
|
||||
boundaries. Neither test extracts a cut from a solid.
|
||||
|
||||
`reversed_large_arc_conversion_rechecks_actual_connectivity_without_snapping`
|
||||
checks the converted result too: a reversed radius-`1e12` arc with start `0.1` and
|
||||
sweep `1.0` radians fails at `tol=1e-9` mm but is accepted at `0.01` mm. This is an
|
||||
arithmetic/closure regression, not evidence of that accuracy against a source.
|
||||
|
||||
[NOWN-DEV's PR #2068][producer-pr], reviewed as an open proposal rather than
|
||||
merged code, contains Stage A producer/export work and a tilted-cylinder test.
|
||||
Review that evidence and its actual mesh/fitting guarantees before reusing it.
|
||||
Its body-name/loop/`sweep_deg` output is a distinct schema; W01a supplies no adapter
|
||||
and promises no automatic compatibility. Agree conversion, accuracy and source
|
||||
identity handling with that contributor before further overlapping claims.
|
||||
|
||||
W01a can unblock review of representation and structural invariants. W02-W04 still
|
||||
need the producer contract for source/provenance, outcomes, completeness and
|
||||
accuracy, plus an agreed kernel/solidcore boundary. Persistent Drawing files,
|
||||
fonts, profiles and PDF are not prerequisites for standalone section work.
|
||||
|
||||
[issue]: https://github.com/tangency-labs/solidcraft/issues/2053
|
||||
[pr]: https://github.com/tangency-labs/solidcraft/pull/2085
|
||||
[plan]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6234779815
|
||||
[producer-pr]: https://github.com/tangency-labs/solidcraft/pull/2068
|
||||
[section-source]: ../crates/geom/src/section.rs
|
||||
[tests-source]: ../crates/geom/src/section_tests.rs
|
||||
[plane-source]: ../crates/geom/src/plane.rs
|
||||
[segment-source]: ../crates/geom/src/profile.rs
|
||||
[section-command]: ../crates/engine/src/cmd/section.rs
|
||||
+11
-3
@@ -1,7 +1,7 @@
|
||||
# UI localization
|
||||
|
||||
SolidCraft embeds its translations and works offline. English, Japanese, German, French, Galician, Korean,
|
||||
Brazilian Portuguese, Russian, Ukrainian, Turkish and Chinese are currently registered in `crates/ui-egui/src/i18n.rs`. Adding a language requires an
|
||||
SolidCraft embeds its translations and works offline. English, Czech, German, Spanish, French, Galician, Italian,
|
||||
Japanese, Korean, Brazilian Portuguese, Russian, Turkish, Ukrainian and Chinese are currently registered in `crates/ui-egui/src/i18n.rs`. Adding a language requires an
|
||||
embedded catalog and one registration there; the preferences selector and validation use the same
|
||||
registry.
|
||||
|
||||
@@ -31,7 +31,7 @@ that looks untranslated. Two guards catch that in the build:
|
||||
|
||||
- `every_complete_catalog_carries_every_english_message` — adding a key to `en.json`
|
||||
without giving it to a complete catalog fails the tests. English is the source catalog,
|
||||
Japanese and Chinese are delivered complete; German, Galician, Korean, Brazilian Portuguese, Russian, French, Ukrainian and
|
||||
Japanese and Chinese are delivered complete; German, Spanish, Galician, Italian, Korean, Brazilian Portuguese, Russian, French, Ukrainian and
|
||||
Turkish are partial — their gaps fall back to English and are reported by `cargo xtask i18n` without
|
||||
failing the build. A new label needs en, ja, zh, de and ko; Russian, French and Ukrainian may
|
||||
follow later.
|
||||
@@ -59,6 +59,14 @@ claim it, or the English-text lookup would silently pick one.
|
||||
|
||||
## Rendering and coverage
|
||||
|
||||
The Italian catalog uses original translations of SolidCraft's English messages, not text
|
||||
extracted from commercial CAD software. Its core terminology is Schizzo (Sketch),
|
||||
Estrusione (Extrude), Raccordo (Fillet), Smusso (Chamfer), Corpo (Body), Componente
|
||||
(Component) and Vincolo (Constraint). Draft geometry is Sformo, while a report draft
|
||||
is Bozza; Counterbore is Lamatura and Countersink is Svasatura. Established technical
|
||||
terms such as Loft, Sweep, Spline and Offset remain unchanged. Command IDs, shortcuts,
|
||||
file formats, units and user-provided names are not translated.
|
||||
|
||||
One immutable `Locale` type handles all translations. Table widgets without an app reference
|
||||
use a render scope that restores the previous locale when it exits, including nested renders.
|
||||
Standalone translated panels enter the same scope. Measure rendered text to size custom
|
||||
|
||||
+2
-1
@@ -9,7 +9,7 @@ Where the expected values come from (the **Source** column):
|
||||
|
||||
No expected value comes from a commercial CAD product.
|
||||
|
||||
**82 / 82 cases pass**; sources: 61 analytic, 21 freecad. 0 known failure(s) tracked in issues; 0 case(s) without expected values yet.
|
||||
**83 / 83 cases pass**; sources: 62 analytic, 21 freecad. 0 known failure(s) tracked in issues; 0 case(s) without expected values yet.
|
||||
|
||||
| Case | Source | Result | Surface distance mm (max / mean) | Method | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -95,6 +95,7 @@ No expected value comes from a commercial CAD product.
|
||||
| 80-fillet-taper-to-fit | analytic | pass | | plate (top 20 -> 2 wide over x 0..40, 15 high) with its top front edge filleted at 3, fit: taper. The radius is the taper law: a clamped cubic B-spline in u = x/40 on 8 equal spans, control radii min(3, 0.9 room) at the Greville abscissae, room = min(20 - 18u, 15) tan 45 deg (top face width, front face height): 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 2.4750, 1.8000. Sections are quarter rounds; V, moments and areas integrated along the edge | |
|
||||
| 81-fillet-spline-radius | analytic | pass | | box 40x30x20 with its top front edge (y=0, z=20) rounded at a radius that is a clamped cubic B-spline in u = x/40, knots 0,0,0,0,0.25,0.6,1,1,1,1, control radii 2, 5, 1.5, 4, 1, 3 (r(0) = 2, r(40) = 3). Sections are quarter rounds; V, moments and areas integrated along the edge, exactly per knot span | |
|
||||
| 82-thin-extrude-frame | analytic | pass | | square frame: the lines' 40 mm square walled 3 mm inside and 1 mm outside (mitred corners) is 42 x 42 less 34 x 34, 10 mm tall: V = (42^2 - 34^2) * 10, A = 2 (42^2 - 34^2) + 4 * 42 * 10 + 4 * 34 * 10 | |
|
||||
| 83-boundary-loop-thicken | analytic | pass | | region = 10 x 10 square + half disc r 5 on its top side: A = 100 + 12.5 pi, centroid by moments (half disc's at 4r / (3 pi) above its diameter); slab of thickness 2 over it (symmetric about z = 0): V = 2A, area = 2A + 2 (30 + 5 pi) | |
|
||||
|
||||
## Not covered by a reference case
|
||||
|
||||
|
||||
+3
-3
@@ -19,9 +19,9 @@ positions refresh those queries; selection, visibility and locking are read live
|
||||
|
||||
Hover picking pauses while the mouse moves the view and resumes when navigation stops.
|
||||
A 3D mouse moving the view leaves it on: the pointer hand is free, so preselection and
|
||||
clicks keep working while the other hand turns the view. Picking uses the cached body
|
||||
bounds directly in model order, so finding each body's bounds does not scan the assembly
|
||||
again.
|
||||
clicks keep working while the other hand turns the view. Picking uses the current camera
|
||||
and the cached body bounds directly in model order, so finding each body's bounds does not
|
||||
scan the assembly again.
|
||||
|
||||
Hovering an assembly's Browser row retains its whole-body highlight buffers during
|
||||
rotation, panning and zoom; the GPU transforms those buffers with the camera. The
|
||||
|
||||
@@ -251,6 +251,79 @@ async function handleComment({ github, context, now = Date.now() }) {
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// Issue numbers a PR claims: `#N` in its title (`<summary> (#N)`) and closing keywords in its body.
|
||||
const MAX_REFS = 5;
|
||||
function referencedIssues(title, body) {
|
||||
const found = new Set();
|
||||
for (const m of String(title || '').matchAll(/(?:^|[\s(])#(\d{1,7})\b/g)) found.add(Number(m[1]));
|
||||
const closing = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d{1,7})\b/gi;
|
||||
for (const m of String(body || '').matchAll(closing)) found.add(Number(m[1]));
|
||||
return [...found].filter((n) => n > 0).slice(0, MAX_REFS);
|
||||
}
|
||||
|
||||
// A pull request was opened (or its title or body edited): it claims each open issue it
|
||||
// references that nobody holds yet, so a draft PR alone is enough (CONTRIBUTING.md).
|
||||
async function handlePull({ github, context }) {
|
||||
const pull = context.payload.pull_request;
|
||||
if (!pull || pull.state !== 'open') return [];
|
||||
const author = pull.user && pull.user.login;
|
||||
if (!author || pull.user.type === 'Bot' || author.endsWith('[bot]')) return [];
|
||||
const claimed = [];
|
||||
for (const n of referencedIssues(pull.title, pull.body)) {
|
||||
if (n === pull.number) continue;
|
||||
let issue;
|
||||
try {
|
||||
({ data: issue } = await github.rest.issues.get({ ...repoArgs(context), issue_number: n }));
|
||||
} catch (e) {
|
||||
if (e.status === 404 || e.status === 410) continue;
|
||||
throw e;
|
||||
}
|
||||
if (issue.pull_request || issue.state !== 'open' || issue.locked) continue;
|
||||
const assignees = (issue.assignees || []).map((a) => a.login);
|
||||
if (assignees.includes(author)) {
|
||||
if (!hasLabel(issue)) {
|
||||
await ensureLabel(github, context);
|
||||
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: n, labels: [LABEL] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (assignees.length || hasLabel(issue)) {
|
||||
const who = assignees.length ? assignees.map((a) => '@' + a).join(', ') : 'someone else';
|
||||
await github.rest.issues.createComment({
|
||||
...repoArgs(context),
|
||||
issue_number: pull.number,
|
||||
body: `#${n} is already claimed by ${who}. If this PR duplicates their work, please close it or offer to help there.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// An older open PR for the issue wins (lowest number), even if it never recorded its claim.
|
||||
const timeline = await getTimeline(github, context, n);
|
||||
const older = linkedPulls(timeline).find((p) => p.number < pull.number && p.user && p.user.login !== author);
|
||||
if (older) {
|
||||
await github.rest.issues.createComment({
|
||||
...repoArgs(context),
|
||||
issue_number: pull.number,
|
||||
body: `#${older.number} claimed #${n} first (the lowest-numbered open PR wins). Please close this one or offer to help there.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await ensureLabel(github, context);
|
||||
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: n, labels: [LABEL] });
|
||||
try {
|
||||
await github.rest.issues.addAssignees({ ...repoArgs(context), issue_number: n, assignees: [author] });
|
||||
} catch (e) {
|
||||
// Assignment can be refused; the label and the comment naming them still stand.
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
...repoArgs(context),
|
||||
issue_number: n,
|
||||
body: `Claimed by @${author} through #${pull.number}. The claim lapses after ${PR_IDLE_DAYS} days without pushes.`,
|
||||
});
|
||||
claimed.push(n);
|
||||
}
|
||||
return claimed;
|
||||
}
|
||||
|
||||
// The daily sweep over open issues labelled `in progress`.
|
||||
async function sweep({ github, context, now = Date.now() }) {
|
||||
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
@@ -286,9 +359,11 @@ async function sweep({ github, context, now = Date.now() }) {
|
||||
module.exports = {
|
||||
LABEL,
|
||||
parseCommand,
|
||||
referencedIssues,
|
||||
isMaintainer,
|
||||
linkedPulls,
|
||||
claimStatus,
|
||||
handleComment,
|
||||
handlePull,
|
||||
sweep,
|
||||
};
|
||||
@@ -39,6 +39,11 @@ function fake(world = {}) {
|
||||
removeAssignees: rec('removeAssignees'),
|
||||
createComment: rec('comment'),
|
||||
listForRepo: async () => ({ data: world.issues || [] }),
|
||||
get: async ({ issue_number }) => {
|
||||
const found = (world.byNumber || {})[issue_number];
|
||||
if (!found) throw Object.assign(new Error('nf'), { status: 404 });
|
||||
return { data: found };
|
||||
},
|
||||
listEventsForTimeline: async () => ({ data: world.timeline || [] }),
|
||||
},
|
||||
},
|
||||
@@ -231,3 +236,62 @@ test('sweep is idempotent: a released issue is no longer listed', async () => {
|
||||
world.issues = [];
|
||||
assert.deepEqual(await claim.sweep({ github: w.github, context: { repo }, now: NOW }), []);
|
||||
});
|
||||
|
||||
test('referencedIssues reads the title and closing keywords, capped', () => {
|
||||
assert.deepEqual(claim.referencedIssues('Fillet: chain (#123)', 'Fixes #9, closes: #10, see #11'), [123, 9, 10]);
|
||||
assert.deepEqual(claim.referencedIssues('no refs', 'mentions #4 only'), []);
|
||||
assert.deepEqual(claim.referencedIssues('#1 #2 #3 #4 #5 #6 #7', ''), [1, 2, 3, 4, 5]);
|
||||
assert.deepEqual(claim.referencedIssues(undefined, null), []);
|
||||
});
|
||||
|
||||
const openIssue = (number, assignees = [], labels = []) => ({
|
||||
number,
|
||||
state: 'open',
|
||||
locked: false,
|
||||
assignees: assignees.map((login) => ({ login })),
|
||||
labels: labels.map((name) => ({ name })),
|
||||
});
|
||||
const pullCtx = (number, title, body = '', login = 'alice') => ({
|
||||
repo,
|
||||
payload: { pull_request: { number, state: 'open', title, body, user: { login, type: 'User' } } },
|
||||
});
|
||||
|
||||
test('a PR referencing a free issue claims it for the author', async () => {
|
||||
const { github, calls } = fake({ byNumber: { 7: openIssue(7) } });
|
||||
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), [7]);
|
||||
assert.deepEqual(find(calls, 'addAssignees')[1], { ...repo, issue_number: 7, assignees: ['alice'] });
|
||||
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['in progress']);
|
||||
assert.match(find(calls, 'comment')[1].body, /Claimed by @alice through #50/);
|
||||
});
|
||||
|
||||
test('a PR on an issue someone else holds comments on the PR and changes nothing', async () => {
|
||||
const { github, calls } = fake({ byNumber: { 7: openIssue(7, ['bob'], ['in progress']) } });
|
||||
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
|
||||
assert.equal(find(calls, 'addAssignees'), undefined);
|
||||
const c = find(calls, 'comment')[1];
|
||||
assert.equal(c.issue_number, 50);
|
||||
assert.match(c.body, /@bob/);
|
||||
});
|
||||
|
||||
test('the lowest-numbered open PR wins an unrecorded claim', async () => {
|
||||
const { github, calls } = fake({ byNumber: { 7: openIssue(7) }, timeline: [pr(42, 'bob')] });
|
||||
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
|
||||
assert.match(find(calls, 'comment')[1].body, /#42 claimed #7 first/);
|
||||
});
|
||||
|
||||
test('the claimant\'s own PR just makes sure the label is there', async () => {
|
||||
const { github, calls } = fake({ byNumber: { 7: openIssue(7, ['alice']) } });
|
||||
assert.deepEqual(await claim.handlePull({ github, context: pullCtx(50, 'Thing (#7)') }), []);
|
||||
assert.deepEqual(names(calls), ['addLabels']);
|
||||
});
|
||||
|
||||
test('PRs from bots, references to PRs, closed and missing issues are ignored', async () => {
|
||||
const w = fake({
|
||||
byNumber: { 7: { ...openIssue(7), pull_request: {} }, 8: { ...openIssue(8), state: 'closed' } },
|
||||
});
|
||||
assert.deepEqual(await claim.handlePull({ github: w.github, context: pullCtx(50, 'x (#7) (#8) (#9)') }), []);
|
||||
const bot = pullCtx(51, 'x (#1)');
|
||||
bot.payload.pull_request.user = { login: 'dependabot[bot]', type: 'Bot' };
|
||||
assert.deepEqual(await claim.handlePull({ github: w.github, context: bot }), []);
|
||||
assert.equal(w.calls.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
// `needs-info`: a short hand-written bug report without reproduction steps is asked for them once.
|
||||
// The author's reply (or an edit that adds them) clears the label; the daily sweep closes what
|
||||
// stays unanswered for INFO_DAYS. Reports filed from the app carry their command, settings and
|
||||
// environment, so they are never asked. Used by .github/workflows/triage.yml.
|
||||
'use strict';
|
||||
|
||||
const { ensureLabel, isMaintainer, labelNames, section } = require('./triage.js');
|
||||
|
||||
const LABEL = 'needs-info';
|
||||
const LABEL_COLOR = 'd4c5f9';
|
||||
const LABEL_DESCRIPTION = 'waiting on the reporter for reproduction steps';
|
||||
const INFO_DAYS = 14;
|
||||
const DAY = 24 * 3600 * 1000;
|
||||
const MAX_TEXT = 20000;
|
||||
|
||||
function repoArgs(context) {
|
||||
return { owner: context.repo.owner, repo: context.repo.repo };
|
||||
}
|
||||
|
||||
// Sections only the app's own reports have.
|
||||
function fromApp(body) {
|
||||
return ['Settings and selection', 'Backtrace', 'Recent commands'].some((s) => section(body, s).trim());
|
||||
}
|
||||
|
||||
// Requests and plans, which aren't reproduced. Crash reports carry the crash folder's contents.
|
||||
const NOT_A_BUG = /^\s*\[(feat|feature|change|enhancement)|feature request|suggestion|\?\s*$|^\s*(tracker|refactor|support|add|allow|crash|how|why|request|missing)\b/i;
|
||||
// The app's report title. A long report opens the browser without its body and is copied to
|
||||
// the clipboard instead (crates/ui-egui/src/issue_report.rs, draft_url), so an empty one wasn't pasted.
|
||||
const APP_TITLE = /^[A-Z][A-Z ]+:\s*(operation failed|unexpected result)\b|^Modeling: unexpected result/;
|
||||
// A report this long describes what it saw; only short ones are asked.
|
||||
const SHORT = 300;
|
||||
const ATTACHMENT = /user-attachments\/|\.solidcraft\b|repro\.json|\.(step|stp|3mf|stl)\b/i;
|
||||
const COMMAND = /["`][a-z_]+\.[a-z_][a-z_.]*["`]/;
|
||||
const VERSION = /\b\d+\.\d+\.\d+\b|\b[0-9a-f]{7,40}\b/i;
|
||||
|
||||
// What a bug report lacks: [] when it can be reproduced.
|
||||
function missing(title, body) {
|
||||
const t = String(title || '');
|
||||
const b = String(body || '').slice(0, MAX_TEXT).replace(/<!--[\s\S]*?-->/g, '');
|
||||
if (NOT_A_BUG.test(t) || fromApp(b) || b.replace(/\s+/g, ' ').trim().length >= SHORT) return [];
|
||||
const steps = `${section(b, 'Reproduction')}\n${section(b, 'Steps to reproduce')}`;
|
||||
const reproducible =
|
||||
steps.replace(/\s+/g, '').length >= 30 || ATTACHMENT.test(b) || COMMAND.test(b) || /^\s*1[.)]\s/m.test(b);
|
||||
if (reproducible) return [];
|
||||
if (APP_TITLE.test(t) && !b.trim()) return ['paste'];
|
||||
const asks = ['steps'];
|
||||
if (!VERSION.test(`${t}\n${b}`)) asks.push('version');
|
||||
return asks;
|
||||
}
|
||||
|
||||
function askBody(asks) {
|
||||
if (asks.includes('paste')) {
|
||||
return [
|
||||
'Thanks for the report! It arrived empty: when a report is too long for the link, SolidCraft copies it to the clipboard instead.',
|
||||
'Please paste it here (or use **Report an Issue** again and paste before submitting).',
|
||||
'',
|
||||
`If we don't hear back within ${INFO_DAYS} days this is closed; reopen it any time.`,
|
||||
].join('\n');
|
||||
}
|
||||
const lines = ['Thanks for the report! To reproduce it we need a little more:'];
|
||||
lines.push(
|
||||
'- **Steps to reproduce**: 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; attach that, or the design.',
|
||||
);
|
||||
if (asks.includes('version')) lines.push('- **SolidCraft version** (Help → About) and your OS.');
|
||||
lines.push(
|
||||
'',
|
||||
`Reply here or edit the issue and it goes back to the triage queue. If we don't hear back within ${INFO_DAYS} days it is closed; reopen it any time.`,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// What to ask the reporter of `issue`, or [] (maintainers' and bots' issues are never asked).
|
||||
async function asksFor(github, context, issue, cache) {
|
||||
if (issue.state !== 'open' || issue.pull_request || issue.locked) return [];
|
||||
const names = labelNames(issue);
|
||||
if (names.includes(LABEL) || names.includes('enhancement')) return []; // the feature form labels its issues
|
||||
const asks = missing(issue.title, issue.body);
|
||||
if (!asks.length || !issue.user || issue.user.type === 'Bot') return [];
|
||||
const login = issue.user.login;
|
||||
if (!cache.has(login)) cache.set(login, await isMaintainer(github, context, login));
|
||||
return cache.get(login) ? [] : asks;
|
||||
}
|
||||
|
||||
async function ask(github, context, issue, asks) {
|
||||
await ensureLabel(github, context, LABEL, LABEL_COLOR, LABEL_DESCRIPTION);
|
||||
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: issue.number, labels: [LABEL] });
|
||||
await github.rest.issues.createComment({ ...repoArgs(context), issue_number: issue.number, body: askBody(asks) });
|
||||
}
|
||||
|
||||
// issues.opened.
|
||||
async function handleOpened({ github, context }) {
|
||||
const issue = context.payload.issue;
|
||||
if (!issue) return false;
|
||||
const asks = await asksFor(github, context, issue, new Map());
|
||||
if (!asks.length) return false;
|
||||
await ask(github, context, issue, asks);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Workflow dispatch: the same ask over the open issues filed before the bot. With `dryRun` it
|
||||
// changes nothing and returns what it would ask.
|
||||
async function backfill({ github, context, dryRun = true }) {
|
||||
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
...repoArgs(context),
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
const cache = new Map();
|
||||
const plan = [];
|
||||
for (const issue of issues) {
|
||||
const asks = await asksFor(github, context, issue, cache);
|
||||
if (!asks.length) continue;
|
||||
plan.push({ number: issue.number, title: issue.title, asks });
|
||||
if (!dryRun) await ask(github, context, issue, asks);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
async function clear(github, context, number) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ ...repoArgs(context), issue_number: number, name: LABEL });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// issue_comment.created / issues.edited: the reporter answered.
|
||||
async function handleReply({ github, context }) {
|
||||
const { issue, comment } = context.payload;
|
||||
if (!issue || !labelNames(issue).includes(LABEL)) return false;
|
||||
const author = issue.user && issue.user.login;
|
||||
const actor = comment ? comment.user.login : context.payload.sender && context.payload.sender.login;
|
||||
if (!author || actor !== author) return false;
|
||||
if (!comment && missing(issue.title, issue.body).length) return false; // an edit that still lacks steps
|
||||
await clear(github, context, issue.number);
|
||||
return true;
|
||||
}
|
||||
|
||||
// When the label was last added, from the timeline; the issue's update time if it can't be found.
|
||||
async function labeledAt(github, context, issue) {
|
||||
const events = await github.paginate(github.rest.issues.listEventsForTimeline, {
|
||||
...repoArgs(context),
|
||||
issue_number: issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
let at = null;
|
||||
for (const ev of events) if (ev.event === 'labeled' && ev.label && ev.label.name === LABEL) at = ev.created_at;
|
||||
return new Date(at || issue.updated_at).getTime();
|
||||
}
|
||||
|
||||
// The daily sweep: close what has waited INFO_DAYS for its reporter.
|
||||
async function sweep({ github, context, now = Date.now() }) {
|
||||
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
...repoArgs(context),
|
||||
state: 'open',
|
||||
labels: LABEL,
|
||||
per_page: 100,
|
||||
});
|
||||
const closed = [];
|
||||
for (const issue of issues) {
|
||||
if (issue.pull_request || issue.locked) continue;
|
||||
if (now - (await labeledAt(github, context, issue)) < INFO_DAYS * DAY) continue;
|
||||
await github.rest.issues.createComment({
|
||||
...repoArgs(context),
|
||||
issue_number: issue.number,
|
||||
body: `Closing for now: we couldn't reproduce this without more detail. Reopen it with steps to reproduce and we'll pick it up.`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
...repoArgs(context),
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
closed.push(issue.number);
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
module.exports = { LABEL, missing, handleOpened, handleReply, sweep, backfill };
|
||||
@@ -0,0 +1,148 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const info = require('./needs-info.js');
|
||||
|
||||
const repo = { owner: 'tangency-labs', repo: 'solidcraft' };
|
||||
const NOW = Date.parse('2026-10-18T12:00:00Z');
|
||||
const DAY = 24 * 3600 * 1000;
|
||||
const ago = (ms) => new Date(NOW - ms).toISOString();
|
||||
|
||||
function fake(world = {}) {
|
||||
const calls = [];
|
||||
const rec = (name) => async (args) => {
|
||||
calls.push([name, args]);
|
||||
return { data: {} };
|
||||
};
|
||||
const github = {
|
||||
paginate: async (fn, args) => (await fn(args)).data,
|
||||
rest: {
|
||||
repos: {
|
||||
getCollaboratorPermissionLevel: async ({ username }) => ({
|
||||
data: { permission: (world.perms || {})[username] || 'none' },
|
||||
}),
|
||||
},
|
||||
issues: {
|
||||
getLabel: async () => ({ data: {} }),
|
||||
createLabel: rec('createLabel'),
|
||||
addLabels: rec('addLabels'),
|
||||
removeLabel: rec('removeLabel'),
|
||||
createComment: rec('comment'),
|
||||
update: rec('update'),
|
||||
listForRepo: async () => ({ data: world.issues || [] }),
|
||||
listEventsForTimeline: async () => ({ data: world.timeline || [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
return { github, calls };
|
||||
}
|
||||
const find = (calls, n) => calls.find((c) => c[0] === n);
|
||||
const labels = (names) => names.map((name) => ({ name }));
|
||||
const long = 'x'.repeat(400);
|
||||
|
||||
test('missing: short reports without steps are asked, with the version when it is absent', () => {
|
||||
assert.deepEqual(info.missing('Loft does not work', 'The Loft operation is not working.'), ['steps', 'version']);
|
||||
assert.deepEqual(info.missing('Loft does not work', 'Broken on 0.15.0.'), ['steps']);
|
||||
});
|
||||
|
||||
test('missing: empty app reports are asked to paste the copied report', () => {
|
||||
assert.deepEqual(info.missing('EXTRUDE: operation failed', ''), ['paste']);
|
||||
assert.deepEqual(info.missing('Modeling: unexpected result', ' '), ['paste']);
|
||||
});
|
||||
|
||||
test('missing: reproducible, long, app-filed and non-bug issues are left alone', () => {
|
||||
assert.deepEqual(info.missing('Loft fails', '## Reproduction\n1. sketch two circles\n2. loft them'), []);
|
||||
assert.deepEqual(info.missing('Loft fails', 'see design.solidcraft attached'), []);
|
||||
assert.deepEqual(info.missing('Loft fails', 'runs `solid.loft` and fails'), []);
|
||||
assert.deepEqual(info.missing('Loft fails', long), []);
|
||||
assert.deepEqual(info.missing('FILLET: operation failed', '## Settings and selection\n[["solid.fillet"]]'), []);
|
||||
assert.deepEqual(info.missing('Feature request: dark mode', 'please'), []);
|
||||
assert.deepEqual(info.missing('[FEAT] threads', 'please'), []);
|
||||
assert.deepEqual(info.missing('How do I draw a polygon?', ''), []);
|
||||
assert.deepEqual(info.missing('Crash: SolidCraft 0.15.0 ended without a clean exit', ''), []);
|
||||
});
|
||||
|
||||
const opened = (title, body, login = 'reporter') => ({
|
||||
repo,
|
||||
payload: { issue: { number: 4, state: 'open', title, body, labels: [], user: { login, type: 'User' } } },
|
||||
});
|
||||
|
||||
test('an opened thin report is labelled and asked once', async () => {
|
||||
const { github, calls } = fake();
|
||||
assert.equal(await info.handleOpened({ github, context: opened('Loft broken', 'does not work') }), true);
|
||||
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['needs-info']);
|
||||
const body = find(calls, 'comment')[1].body;
|
||||
assert.match(body, /Steps to reproduce/);
|
||||
assert.match(body, /SolidCraft version/);
|
||||
assert.match(body, /14 days/);
|
||||
});
|
||||
|
||||
test('an empty app report is asked to paste, a maintainer\'s note is never asked', async () => {
|
||||
let w = fake();
|
||||
await info.handleOpened({ github: w.github, context: opened('EXTRUDE: operation failed', '') });
|
||||
assert.match(find(w.calls, 'comment')[1].body, /clipboard/);
|
||||
w = fake({ perms: { carol: 'write' } });
|
||||
assert.equal(await info.handleOpened({ github: w.github, context: opened('Loft broken', 'x', 'carol') }), false);
|
||||
assert.equal(w.calls.length, 0);
|
||||
});
|
||||
|
||||
const reply = (login, { comment = true, body = 'x', issueLabels = ['needs-info'] } = {}) => ({
|
||||
repo,
|
||||
payload: {
|
||||
issue: { number: 4, title: 'Loft broken', body, labels: labels(issueLabels), user: { login: 'reporter' } },
|
||||
...(comment ? { comment: { user: { login } } } : { sender: { login } }),
|
||||
},
|
||||
});
|
||||
|
||||
test('the reporter\'s comment clears needs-info; someone else\'s does not', async () => {
|
||||
let w = fake();
|
||||
assert.equal(await info.handleReply({ github: w.github, context: reply('reporter') }), true);
|
||||
assert.equal(find(w.calls, 'removeLabel')[1].name, 'needs-info');
|
||||
w = fake();
|
||||
assert.equal(await info.handleReply({ github: w.github, context: reply('bystander') }), false);
|
||||
w = fake();
|
||||
assert.equal(await info.handleReply({ github: w.github, context: reply('reporter', { issueLabels: [] }) }), false);
|
||||
assert.equal(w.calls.length, 0);
|
||||
});
|
||||
|
||||
test('an edit clears needs-info only once the steps are there', async () => {
|
||||
let w = fake();
|
||||
assert.equal(await info.handleReply({ github: w.github, context: reply('reporter', { comment: false, body: 'still broken' }) }), false);
|
||||
w = fake();
|
||||
const fixed = reply('reporter', { comment: false, body: '## Reproduction\n1. sketch a circle\n2. loft it to a point' });
|
||||
assert.equal(await info.handleReply({ github: w.github, context: fixed }), true);
|
||||
});
|
||||
|
||||
test('the sweep closes what waited 14 days, and keeps younger ones', async () => {
|
||||
const issue = { number: 4, locked: false, updated_at: ago(30 * DAY), labels: labels(['needs-info']) };
|
||||
const labeled = (at) => ({ event: 'labeled', label: { name: 'needs-info' }, created_at: at });
|
||||
let w = fake({ issues: [issue], timeline: [labeled(ago(15 * DAY))] });
|
||||
assert.deepEqual(await info.sweep({ github: w.github, context: { repo }, now: NOW }), [4]);
|
||||
assert.deepEqual(find(w.calls, 'update')[1], { ...repo, issue_number: 4, state: 'closed', state_reason: 'not_planned' });
|
||||
w = fake({ issues: [issue], timeline: [labeled(ago(20 * DAY)), labeled(ago(3 * DAY))] });
|
||||
assert.deepEqual(await info.sweep({ github: w.github, context: { repo }, now: NOW }), []);
|
||||
assert.equal(w.calls.length, 0);
|
||||
});
|
||||
|
||||
test('backfill asks open thin reports once, and the dry run writes nothing', async () => {
|
||||
const issues = [
|
||||
{ number: 1, state: 'open', title: 'Loft broken', body: 'nope', labels: [], user: { login: 'a', type: 'User' } },
|
||||
{ number: 2, state: 'open', title: 'Loft broken', body: 'nope', labels: labels(['needs-info']), user: { login: 'a' } },
|
||||
{ number: 3, state: 'open', title: 'x', body: 'nope', labels: [], pull_request: {}, user: { login: 'a' } },
|
||||
];
|
||||
let w = fake({ issues });
|
||||
const plan = await info.backfill({ github: w.github, context: { repo }, dryRun: true });
|
||||
assert.deepEqual(plan.map((p) => p.number), [1]);
|
||||
assert.equal(w.calls.length, 0);
|
||||
w = fake({ issues });
|
||||
await info.backfill({ github: w.github, context: { repo }, dryRun: false });
|
||||
assert.equal(find(w.calls, 'addLabels')[1].issue_number, 1);
|
||||
});
|
||||
|
||||
test('an issue from the feature form is never asked for steps', async () => {
|
||||
const { github, calls } = fake();
|
||||
const ctx = opened('Dark mode', '### What are you trying to do\nwork at night');
|
||||
ctx.payload.issue.labels = labels(['enhancement']);
|
||||
assert.equal(await info.handleOpened({ github, context: ctx }), false);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
// The triage bot's logic, used by .github/workflows/triage.yml through actions/github-script.
|
||||
// New issues and PRs get `area:*` and type labels from fixed rules; an issue nobody has given a
|
||||
// priority yet carries `needs-triage`. Maintainers set labels with `/triage`. The rules only ever
|
||||
// add labels, so a person's correction is never undone.
|
||||
// Everything takes the github-script `github` client and `context`, so the tests can pass fakes.
|
||||
'use strict';
|
||||
|
||||
const NEEDS_TRIAGE = 'needs-triage';
|
||||
const NEEDS_TRIAGE_COLOR = 'ededed';
|
||||
const NEEDS_TRIAGE_DESCRIPTION = 'no priority yet; a maintainer sorts it with /triage';
|
||||
const MAX_TOKENS = 10;
|
||||
const MAX_TEXT = 20000;
|
||||
|
||||
// Changed paths of a PR → area. First match wins per file.
|
||||
const PATH_AREAS = [
|
||||
[/^crates\/sketch\//, 'area:sketch'],
|
||||
[/^crates\/(kernel|geom)\//, 'area:kernel'],
|
||||
[/^crates\/doc\//, 'area:doc'],
|
||||
[/^crates\/io\//, 'area:io'],
|
||||
[/^crates\/(render|gpu)\//, 'area:render'],
|
||||
[/^(crates\/(ui-egui|space-mouse|threedx-client)|apps\/(solidcraft|solidcraft-web))\//, 'area:ui'],
|
||||
[/^(\.github\/|xtask\/|scripts\/|tools\/|docs\/|packaging\/|[^/]+\.md$)/, 'area:infra'],
|
||||
];
|
||||
|
||||
// Command id prefix (the in-app reports quote it) → area.
|
||||
const COMMAND_AREAS = {
|
||||
sketch: 'area:sketch',
|
||||
solid: 'area:kernel',
|
||||
surface: 'area:kernel',
|
||||
sheet: 'area:kernel',
|
||||
plastic: 'area:kernel',
|
||||
construct: 'area:kernel',
|
||||
body: 'area:kernel',
|
||||
component: 'area:doc',
|
||||
joint: 'area:doc',
|
||||
parameters: 'area:doc',
|
||||
timeline: 'area:doc',
|
||||
occurrence: 'area:doc',
|
||||
motion: 'area:doc',
|
||||
contact: 'area:doc',
|
||||
explode: 'area:doc',
|
||||
file: 'area:io',
|
||||
render: 'area:render',
|
||||
appearance: 'area:render',
|
||||
view: 'area:ui',
|
||||
browser: 'area:ui',
|
||||
selection: 'area:ui',
|
||||
};
|
||||
|
||||
// Words in an issue's title → area. Conservative: a miss leaves the issue for a person.
|
||||
const WORD_AREAS = [
|
||||
[/\b(sketch|constraint|dimension|solver|spline)/i, 'area:sketch'],
|
||||
[/\b(fillet|chamfer|boolean|shell|loft|sweep|revolve|extrude|thread|hole|b-?rep|combine|offset|mirror|kernel|pattern)(s|ed|ing)?\b/i, 'area:kernel'],
|
||||
[/\b(step|iges|3mf|stl|gltf|dxf|obj|import|export)\b/i, 'area:io'],
|
||||
[/\b(render|gpu|wgpu|shader|opengl|vulkan|metal|appearance)\b/i, 'area:render'],
|
||||
[/\b(joint|assembly|timeline|parameter|component)/i, 'area:doc'],
|
||||
[/\b(toolbar|dialog|viewport|panel|button|shortcut|menu|window|space ?mouse|3dconnexion|spacenavd)/i, 'area:ui'],
|
||||
];
|
||||
|
||||
const CRASH = /^\s*crash\b|\bpanic(ked)?\b|\bcaught panic\b/i;
|
||||
const FEATURE = /^\s*\[?feature( request)?\]?\b/i;
|
||||
// The in-app reports: `<COMMAND>: operation failed` / `Modeling: unexpected result`.
|
||||
const IN_APP_FAILURE = /:\s*(operation failed|unexpected result)\b/i;
|
||||
|
||||
function repoArgs(context) {
|
||||
return { owner: context.repo.owner, repo: context.repo.repo };
|
||||
}
|
||||
|
||||
// The text under a `## <name>` heading (`###` in issue forms), up to the next heading.
|
||||
function section(body, name) {
|
||||
const lines = body.split('\n');
|
||||
const start = lines.findIndex((l) => new RegExp(`^#{2,3}\\s*${name}\\s*$`, 'i').test(l.trim()));
|
||||
if (start < 0) return '';
|
||||
const rest = lines.slice(start + 1);
|
||||
const end = rest.findIndex((l) => /^#{2,3}\s/.test(l));
|
||||
return (end < 0 ? rest : rest.slice(0, end)).join('\n');
|
||||
}
|
||||
|
||||
function labelNames(item) {
|
||||
return (item.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
|
||||
}
|
||||
|
||||
function hasPriority(names) {
|
||||
return names.some((n) => n.startsWith('priority:'));
|
||||
}
|
||||
|
||||
// Labels for an issue from its title and body.
|
||||
function classifyIssue(title, body) {
|
||||
const t = String(title || '').slice(0, 500);
|
||||
const b = String(body || '').slice(0, MAX_TEXT);
|
||||
const labels = new Set();
|
||||
if (CRASH.test(t)) {
|
||||
labels.add('bug');
|
||||
labels.add('priority:P1'); // a crash loses work (CLAUDE.md, "Never crash")
|
||||
} else if (IN_APP_FAILURE.test(t) || /^##\s*(behavior|actual behavior)\b/im.test(b)) {
|
||||
labels.add('bug');
|
||||
} else if (FEATURE.test(t)) {
|
||||
labels.add('enhancement');
|
||||
}
|
||||
// The title names the feature; the command quoted in the report's own section is the fallback
|
||||
// (a report's history quotes earlier commands too, so the rest of the body is not read).
|
||||
let area = null;
|
||||
for (const [re, a] of WORD_AREAS) {
|
||||
if (re.test(t)) {
|
||||
area = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!area) {
|
||||
const own = `${t}\n${section(b, 'Settings and selection')}\n${section(b, 'Command')}`;
|
||||
const m = /["`]([a-z_]+)\.[a-z_][a-z_.]*["`]/.exec(own);
|
||||
area = (m && COMMAND_AREAS[m[1]]) || null;
|
||||
}
|
||||
if (area) labels.add(area);
|
||||
return [...labels];
|
||||
}
|
||||
|
||||
// Labels for a PR from its changed paths.
|
||||
function classifyPaths(paths) {
|
||||
const labels = new Set();
|
||||
for (const p of paths) {
|
||||
for (const [re, area] of PATH_AREAS) {
|
||||
if (re.test(p)) {
|
||||
labels.add(area);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...labels].sort();
|
||||
}
|
||||
|
||||
async function ensureLabel(github, context, name, color, description) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ ...repoArgs(context), name });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
try {
|
||||
await github.rest.issues.createLabel({ ...repoArgs(context), name, color, description });
|
||||
} catch (e2) {
|
||||
if (e2.status !== 422) throw e2; // created meanwhile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pullPaths(github, context, number) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
...repoArgs(context),
|
||||
pull_number: number,
|
||||
per_page: 100,
|
||||
});
|
||||
return files.map((f) => f.filename);
|
||||
}
|
||||
|
||||
// The labels the rules would add to an issue or PR that it doesn't already carry.
|
||||
async function proposal(github, context, item) {
|
||||
const current = labelNames(item);
|
||||
let wanted;
|
||||
if (item.pull_request) {
|
||||
wanted = classifyPaths(await pullPaths(github, context, item.number));
|
||||
} else {
|
||||
wanted = classifyIssue(item.title, item.body);
|
||||
if (!hasPriority(current) && !wanted.some((l) => l.startsWith('priority:'))) wanted.push(NEEDS_TRIAGE);
|
||||
}
|
||||
return wanted.filter((l) => !current.includes(l));
|
||||
}
|
||||
|
||||
async function apply(github, context, number, labels) {
|
||||
if (!labels.length) return;
|
||||
if (labels.includes(NEEDS_TRIAGE)) {
|
||||
await ensureLabel(github, context, NEEDS_TRIAGE, NEEDS_TRIAGE_COLOR, NEEDS_TRIAGE_DESCRIPTION);
|
||||
}
|
||||
await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: number, labels });
|
||||
}
|
||||
|
||||
// issues.opened / pull_request_target.opened.
|
||||
async function handleOpened({ github, context }) {
|
||||
const issue = context.payload.issue;
|
||||
const pull = context.payload.pull_request;
|
||||
const item = issue || (pull && { ...pull, pull_request: {} });
|
||||
if (!item || item.state !== 'open') return [];
|
||||
const labels = await proposal(github, context, item);
|
||||
await apply(github, context, item.number, labels);
|
||||
return labels;
|
||||
}
|
||||
|
||||
// issues.labeled: a priority settles triage.
|
||||
async function handleLabeled({ github, context }) {
|
||||
const issue = context.payload.issue;
|
||||
const label = context.payload.label;
|
||||
if (!issue || !label || !label.name.startsWith('priority:')) return false;
|
||||
if (!labelNames(issue).includes(NEEDS_TRIAGE)) return false;
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ ...repoArgs(context), issue_number: issue.number, name: NEEDS_TRIAGE });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// `/triage area:kernel P1 bug -enhancement`: tokens name existing labels; `P1` is short for
|
||||
// `priority:P1` and replaces any other priority; a leading `-` removes.
|
||||
function parseTriage(body) {
|
||||
const line = String(body || '').trimStart().split('\n')[0];
|
||||
const m = /^\/triage(\s.*)?$/.exec(line.trimEnd());
|
||||
if (!m) return null;
|
||||
const add = [];
|
||||
const remove = [];
|
||||
for (const raw of (m[1] || '').trim().split(/\s+/).filter(Boolean).slice(0, MAX_TOKENS)) {
|
||||
const minus = raw.startsWith('-');
|
||||
let name = minus ? raw.slice(1) : raw;
|
||||
if (/^p[0-3]$/i.test(name)) name = 'priority:' + name.toUpperCase();
|
||||
if (name) (minus ? remove : add).push(name);
|
||||
}
|
||||
return { add, remove };
|
||||
}
|
||||
|
||||
async function isMaintainer(github, context, login) {
|
||||
if (login === context.repo.owner) return true;
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ ...repoArgs(context), username: login });
|
||||
return ['admin', 'write', 'maintain'].includes(data.permission);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function react(github, context, content) {
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
...repoArgs(context),
|
||||
comment_id: context.payload.comment.id,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
// issue_comment.created.
|
||||
async function handleComment({ github, context }) {
|
||||
const { issue, comment } = context.payload;
|
||||
if (!issue || !comment || comment.user.type === 'Bot' || comment.user.login.endsWith('[bot]')) return 'skip';
|
||||
const cmd = parseTriage(comment.body);
|
||||
if (!cmd) return 'skip';
|
||||
if (!(await isMaintainer(github, context, comment.user.login))) {
|
||||
await react(github, context, 'eyes');
|
||||
return 'denied';
|
||||
}
|
||||
const known = new Set(
|
||||
(await github.paginate(github.rest.issues.listLabelsForRepo, { ...repoArgs(context), per_page: 100 })).map((l) => l.name),
|
||||
);
|
||||
const unknown = [...cmd.add, ...cmd.remove].filter((n) => !known.has(n));
|
||||
if (unknown.length) {
|
||||
await react(github, context, 'confused');
|
||||
await github.rest.issues.createComment({
|
||||
...repoArgs(context),
|
||||
issue_number: issue.number,
|
||||
body: `No such label: ${unknown.map((n) => '`' + n + '`').join(', ')}. Nothing was changed.`,
|
||||
});
|
||||
return 'unknown';
|
||||
}
|
||||
const current = labelNames(issue);
|
||||
const remove = new Set(cmd.remove);
|
||||
if (cmd.add.some((n) => n.startsWith('priority:'))) {
|
||||
for (const n of current) if (n.startsWith('priority:') && !cmd.add.includes(n)) remove.add(n);
|
||||
}
|
||||
if (cmd.add.length || remove.size) remove.add(NEEDS_TRIAGE);
|
||||
for (const name of remove) {
|
||||
if (!current.includes(name)) continue;
|
||||
await github.rest.issues.removeLabel({ ...repoArgs(context), issue_number: issue.number, name });
|
||||
}
|
||||
const add = cmd.add.filter((n) => !current.includes(n));
|
||||
if (add.length) await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: issue.number, labels: add });
|
||||
await react(github, context, '+1');
|
||||
return 'triage';
|
||||
}
|
||||
|
||||
// Workflow dispatch: the rules over every open issue and PR with no labels at all. With `dryRun`
|
||||
// it changes nothing and returns what it would do.
|
||||
async function backfill({ github, context, dryRun = true }) {
|
||||
const items = await github.paginate(github.rest.issues.listForRepo, {
|
||||
...repoArgs(context),
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
const plan = [];
|
||||
for (const item of items) {
|
||||
if (labelNames(item).length || item.locked) continue;
|
||||
const labels = await proposal(github, context, item);
|
||||
plan.push({ number: item.number, pull: Boolean(item.pull_request), title: item.title, labels });
|
||||
if (!dryRun) await apply(github, context, item.number, labels);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
NEEDS_TRIAGE,
|
||||
classifyIssue,
|
||||
classifyPaths,
|
||||
ensureLabel,
|
||||
isMaintainer,
|
||||
labelNames,
|
||||
section,
|
||||
parseTriage,
|
||||
handleOpened,
|
||||
handleLabeled,
|
||||
handleComment,
|
||||
backfill,
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const triage = require('./triage.js');
|
||||
|
||||
const repo = { owner: 'tangency-labs', repo: 'solidcraft' };
|
||||
|
||||
// A fake github-script client that records writes. `world` describes the repo.
|
||||
function fake(world = {}) {
|
||||
const calls = [];
|
||||
const rec = (name) => async (args) => {
|
||||
calls.push([name, args]);
|
||||
return { data: {} };
|
||||
};
|
||||
const github = {
|
||||
paginate: async (fn, args) => (await fn(args)).data,
|
||||
rest: {
|
||||
repos: {
|
||||
getCollaboratorPermissionLevel: async ({ username }) => ({
|
||||
data: { permission: (world.perms || {})[username] || 'none' },
|
||||
}),
|
||||
},
|
||||
pulls: { listFiles: async () => ({ data: (world.files || []).map((filename) => ({ filename })) }) },
|
||||
reactions: { createForIssueComment: rec('react') },
|
||||
issues: {
|
||||
getLabel: async () => {
|
||||
if (world.labelMissing) throw Object.assign(new Error('nf'), { status: 404 });
|
||||
return { data: {} };
|
||||
},
|
||||
createLabel: rec('createLabel'),
|
||||
addLabels: rec('addLabels'),
|
||||
removeLabel: rec('removeLabel'),
|
||||
createComment: rec('comment'),
|
||||
listForRepo: async () => ({ data: world.items || [] }),
|
||||
listLabelsForRepo: async () => ({
|
||||
data: (world.labels || ['bug', 'enhancement', 'area:kernel', 'priority:P1', 'priority:P2', 'needs-triage']).map(
|
||||
(name) => ({ name }),
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
return { github, calls };
|
||||
}
|
||||
|
||||
const find = (calls, n) => calls.find((c) => c[0] === n);
|
||||
const all = (calls, n) => calls.filter((c) => c[0] === n);
|
||||
const labels = (names) => names.map((name) => ({ name }));
|
||||
|
||||
test('classifyIssue: in-app failure reports get bug and the area from the title', () => {
|
||||
assert.deepEqual(triage.classifyIssue('FILLET: operation failed', '## Actual behavior\nx'), ['bug', 'area:kernel']);
|
||||
assert.deepEqual(triage.classifyIssue('CREATE SKETCH: operation failed', ''), ['bug', 'area:sketch']);
|
||||
});
|
||||
|
||||
test('classifyIssue: the report\'s own command is the fallback, not its history', () => {
|
||||
const body = '## Recent commands\n"sketch.line"\n## Settings and selection\n [["file.export", {}]]\n## Environment\n';
|
||||
assert.deepEqual(triage.classifyIssue('Modeling: unexpected result', body), ['bug', 'area:io']);
|
||||
assert.deepEqual(triage.classifyIssue('Modeling: unexpected result', '## Recent commands\n"sketch.line"\n'), ['bug']);
|
||||
});
|
||||
|
||||
test('classifyIssue: crashes are P1 bugs, the command named in the title gives the area', () => {
|
||||
assert.deepEqual(triage.classifyIssue('Crash: ended without a clean exit while running `solid.loft`', ''), [
|
||||
'bug',
|
||||
'priority:P1',
|
||||
'area:kernel',
|
||||
]);
|
||||
assert.deepEqual(triage.classifyIssue('Joining 90 segments: caught panic at truck', ''), ['bug', 'priority:P1']);
|
||||
});
|
||||
|
||||
test('classifyIssue: feature requests and unmatched text', () => {
|
||||
assert.deepEqual(triage.classifyIssue('Feature request: macOS please make use of menubar', ''), ['enhancement', 'area:ui']);
|
||||
assert.deepEqual(triage.classifyIssue('Refactor with types', 'words'), []);
|
||||
assert.deepEqual(triage.classifyIssue(undefined, undefined), []);
|
||||
});
|
||||
|
||||
test('classifyPaths maps changed files to areas', () => {
|
||||
assert.deepEqual(
|
||||
triage.classifyPaths(['crates/kernel/src/a.rs', 'crates/geom/b.rs', 'crates/ui-egui/c.rs', 'README.md', 'Cargo.lock']),
|
||||
['area:infra', 'area:kernel', 'area:ui'],
|
||||
);
|
||||
assert.deepEqual(triage.classifyPaths(['crates/engine/src/cmd/x.rs']), []);
|
||||
});
|
||||
|
||||
test('parseTriage', () => {
|
||||
assert.deepEqual(triage.parseTriage('/triage area:kernel p1 -enhancement'), {
|
||||
add: ['area:kernel', 'priority:P1'],
|
||||
remove: ['enhancement'],
|
||||
});
|
||||
assert.deepEqual(triage.parseTriage('/triage'), { add: [], remove: [] });
|
||||
assert.equal(triage.parseTriage('/triaged'), null);
|
||||
assert.equal(triage.parseTriage('please /triage'), null);
|
||||
assert.equal(triage.parseTriage('/triage ' + 'bug '.repeat(50)).add.length, 10);
|
||||
});
|
||||
|
||||
test('an opened issue gets its labels plus needs-triage, which is created if missing', async () => {
|
||||
const { github, calls } = fake({ labelMissing: true });
|
||||
const issue = { number: 5, state: 'open', title: 'SHELL: operation failed', body: '', labels: [] };
|
||||
assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { issue } } }), [
|
||||
'bug',
|
||||
'area:kernel',
|
||||
'needs-triage',
|
||||
]);
|
||||
assert.ok(find(calls, 'createLabel'));
|
||||
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['bug', 'area:kernel', 'needs-triage']);
|
||||
});
|
||||
|
||||
test('an opened issue keeps the labels it was filed with and skips needs-triage with a priority', async () => {
|
||||
const { github, calls } = fake();
|
||||
const issue = { number: 5, state: 'open', title: 'Crash: x', body: '', labels: labels(['bug']) };
|
||||
assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { issue } } }), ['priority:P1']);
|
||||
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['priority:P1']);
|
||||
});
|
||||
|
||||
test('an opened PR is labelled from its paths and never gets needs-triage', async () => {
|
||||
const { github, calls } = fake({ files: ['crates/sketch/src/solve.rs'] });
|
||||
const pull_request = { number: 9, state: 'open', title: 'Sketch: thing', body: '', labels: [] };
|
||||
assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { pull_request } } }), ['area:sketch']);
|
||||
assert.deepEqual(find(calls, 'addLabels')[1], { ...repo, issue_number: 9, labels: ['area:sketch'] });
|
||||
});
|
||||
|
||||
test('a priority label clears needs-triage', async () => {
|
||||
const { github, calls } = fake();
|
||||
const issue = { number: 5, labels: labels(['needs-triage', 'priority:P2']) };
|
||||
assert.equal(await triage.handleLabeled({ github, context: { repo, payload: { issue, label: { name: 'priority:P2' } } } }), true);
|
||||
assert.equal(find(calls, 'removeLabel')[1].name, 'needs-triage');
|
||||
const other = fake();
|
||||
assert.equal(
|
||||
await triage.handleLabeled({ github: other.github, context: { repo, payload: { issue, label: { name: 'bug' } } } }),
|
||||
false,
|
||||
);
|
||||
assert.equal(other.calls.length, 0);
|
||||
});
|
||||
|
||||
const comment = (body, login = 'carol', issueLabels = []) => ({
|
||||
repo,
|
||||
payload: {
|
||||
issue: { number: 5, labels: labels(issueLabels) },
|
||||
comment: { id: 1, body, user: { login, type: 'User' } },
|
||||
},
|
||||
});
|
||||
|
||||
test('/triage by a maintainer sets labels, replaces the priority and clears needs-triage', async () => {
|
||||
const { github, calls } = fake({ perms: { carol: 'write' } });
|
||||
const ctx = comment('/triage area:kernel P1', 'carol', ['priority:P2', 'needs-triage', 'bug']);
|
||||
assert.equal(await triage.handleComment({ github, context: ctx }), 'triage');
|
||||
assert.deepEqual(all(calls, 'removeLabel').map((c) => c[1].name).sort(), ['needs-triage', 'priority:P2']);
|
||||
assert.deepEqual(find(calls, 'addLabels')[1].labels, ['area:kernel', 'priority:P1']);
|
||||
assert.equal(find(calls, 'react')[1].content, '+1');
|
||||
});
|
||||
|
||||
test('/triage from a non-maintainer or with an unknown label changes nothing', async () => {
|
||||
let w = fake();
|
||||
assert.equal(await triage.handleComment({ github: w.github, context: comment('/triage bug', 'mallory') }), 'denied');
|
||||
assert.equal(find(w.calls, 'addLabels'), undefined);
|
||||
w = fake({ perms: { carol: 'admin' } });
|
||||
assert.equal(await triage.handleComment({ github: w.github, context: comment('/triage area:nope') }), 'unknown');
|
||||
assert.equal(find(w.calls, 'addLabels'), undefined);
|
||||
assert.match(find(w.calls, 'comment')[1].body, /area:nope/);
|
||||
});
|
||||
|
||||
test('bots and other comments are ignored', async () => {
|
||||
const { github, calls } = fake();
|
||||
const bot = comment('/triage bug');
|
||||
bot.payload.comment.user = { login: 'x[bot]', type: 'Bot' };
|
||||
assert.equal(await triage.handleComment({ github, context: bot }), 'skip');
|
||||
assert.equal(await triage.handleComment({ github, context: comment('hello') }), 'skip');
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('backfill only touches unlabelled items, and the dry run writes nothing', async () => {
|
||||
const items = [
|
||||
{ number: 1, title: 'EXTRUDE: operation failed', body: '', labels: [] },
|
||||
{ number: 2, title: 'Already sorted', body: '', labels: labels(['bug']) },
|
||||
{ number: 3, title: 'x', body: '', labels: [], pull_request: {} },
|
||||
];
|
||||
let w = fake({ items, files: ['crates/io/src/step.rs'] });
|
||||
const plan = await triage.backfill({ github: w.github, context: { repo }, dryRun: true });
|
||||
assert.deepEqual(
|
||||
plan.map((p) => [p.number, p.labels]),
|
||||
[
|
||||
[1, ['bug', 'area:kernel', 'needs-triage']],
|
||||
[3, ['area:io']],
|
||||
],
|
||||
);
|
||||
assert.equal(w.calls.length, 0);
|
||||
w = fake({ items, files: ['crates/io/src/step.rs'] });
|
||||
await triage.backfill({ github: w.github, context: { repo }, dryRun: false });
|
||||
assert.deepEqual(all(w.calls, 'addLabels').map((c) => c[1].issue_number), [1, 3]);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"source": "analytic",
|
||||
"method": "region = 10 x 10 square + half disc r 5 on its top side: A = 100 + 12.5 pi, centroid by moments (half disc's at 4r / (3 pi) above its diameter); slab of thickness 2 over it (symmetric about z = 0): V = 2A, area = 2A + 2 (30 + 5 pi)",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 278.53981633974485,
|
||||
"area_mm2": 369.9557428756428,
|
||||
"center_of_mass": [
|
||||
4.999999999999999,
|
||||
7.008207500514442,
|
||||
0.0
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
-1.0
|
||||
],
|
||||
"max": [
|
||||
10.0,
|
||||
15.0,
|
||||
1.0
|
||||
]
|
||||
},
|
||||
"faces": 6
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"id": "83-boundary-loop-thicken",
|
||||
"name": "Boundary surface through a loop, thickened",
|
||||
"description": "Four sketch curves on XY (a 10x10 square's bottom, right and left sides, and a half circle of radius 5 bulging up for its top) make one boundary surface; thickened 2 mm symmetrically, the surface then removed.",
|
||||
"exercises": [
|
||||
"boundary_surface",
|
||||
"thicken"
|
||||
],
|
||||
"units": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"area": "mm2",
|
||||
"volume": "mm3"
|
||||
},
|
||||
"conventions": {
|
||||
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
|
||||
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
|
||||
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
|
||||
"extrude_direction": "positive = along sketch plane normal unless stated",
|
||||
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
|
||||
},
|
||||
"parameters": [],
|
||||
"features": [
|
||||
{
|
||||
"op": "sketch",
|
||||
"name": "Loop",
|
||||
"plane": {
|
||||
"ref": "XY",
|
||||
"origin": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"x_dir": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"y_dir": [
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
],
|
||||
"normal": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
10,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "s1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "s1.end",
|
||||
"at": [
|
||||
10,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
10,
|
||||
10
|
||||
]
|
||||
},
|
||||
"id": "s2"
|
||||
},
|
||||
{
|
||||
"type": "arc_three_point",
|
||||
"start": {
|
||||
"ref": "s2.end",
|
||||
"at": [
|
||||
10,
|
||||
10
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
"mid": [
|
||||
5,
|
||||
15
|
||||
],
|
||||
"id": "a3"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "a3.end",
|
||||
"at": [
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"ref": "s1.start",
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "s4"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "boundary_surface",
|
||||
"name": "Patch",
|
||||
"sketch": "Loop",
|
||||
"curves": [
|
||||
"s1",
|
||||
"s2",
|
||||
"a3",
|
||||
"s4"
|
||||
],
|
||||
"result_bodies": [
|
||||
"Sheet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "thicken",
|
||||
"name": "Slab",
|
||||
"bodies": [
|
||||
"Sheet"
|
||||
],
|
||||
"thickness": 2,
|
||||
"symmetric": true,
|
||||
"operation": "new"
|
||||
},
|
||||
{
|
||||
"op": "remove",
|
||||
"name": "Clear",
|
||||
"bodies": [
|
||||
"Sheet"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user