Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b685b7301f | ||
|
|
8f768b65c8 | ||
|
|
932efbf341 | ||
|
|
b8d7b42808 | ||
|
|
a8e8d82f90 | ||
|
|
33cd031701 | ||
|
|
6422ef84d5 | ||
|
|
7c9f8a5895 | ||
|
|
36b6496ad5 | ||
|
|
48986041ed | ||
|
|
c39bd23d07 | ||
|
|
a33c5a7861 | ||
|
|
5c95b0496d | ||
|
|
72aa77dabd | ||
|
|
243db6cb8f | ||
|
|
83c31d77c4 | ||
|
|
ae6a33bf96 | ||
|
|
675c865431 |
No files matched your search
+25
-16
@@ -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
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
changes:
|
||||
name: Select checks
|
||||
# Trusted runs (see the top of this file) use the self-hosted runners.
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
permissions:
|
||||
contents: read
|
||||
# To ask whether the merge queue already tested a commit pushed to main.
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
|
||||
checks:
|
||||
name: Format and repository checks
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -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
|
||||
@@ -202,6 +203,10 @@ jobs:
|
||||
run: cargo xtask licences --check
|
||||
- name: Dependency layers
|
||||
run: cargo xtask layers
|
||||
- name: Sorted lists
|
||||
run: cargo xtask lists
|
||||
- name: Design format pending changes
|
||||
run: bash scripts/test-format-pending.sh
|
||||
- name: Web app offline use and updates (headless Chrome)
|
||||
run: cargo xtask web-offline-test
|
||||
- name: Affected-package selection self-test
|
||||
@@ -213,7 +218,7 @@ jobs:
|
||||
name: Native Linux Clippy
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -223,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
|
||||
@@ -244,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
|
||||
@@ -286,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
|
||||
@@ -434,7 +441,7 @@ jobs:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -447,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
|
||||
@@ -497,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
|
||||
@@ -579,7 +588,7 @@ jobs:
|
||||
ci:
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
if: always()
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Files many pull requests add to are kept so that additions don't conflict: one file per change
|
||||
# or one sorted line per item (CONTRIBUTING.md, "Lists many changes add to"). Fast, no build.
|
||||
# Pull requests get the same checks in CI's "Format and repository checks" job (one runner slot
|
||||
# per PR instead of three); this copy keeps main honest.
|
||||
name: Lists
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
branches: [main]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/**"]
|
||||
pull_request:
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/**"]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/platforms/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -132,9 +132,13 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. Don't claim by commenting or by editing files.
|
||||
issue wins**; if yours lost, close it. A comment or file edit alone does not replace the draft PR.
|
||||
- After opening the draft PR, comment `/claim` on the issue so the bot records the claimant and
|
||||
adds the `in progress` label. **Do both:** the draft PR establishes ownership and `/claim` marks
|
||||
the issue as taken. Reference `#N` in the PR so the bot can find it.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
|
||||
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
|
||||
and wait while it is over 24; build with `nice`.
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+8
-4
@@ -258,6 +258,9 @@ and a fresh CI run:
|
||||
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
|
||||
- **The lowest-numbered open PR for an issue wins.** If yours lost, close it (or offer to help
|
||||
on the other one).
|
||||
- Then comment `/claim` on the issue so the bot records the claimant and adds the `in progress`
|
||||
label. **Do both:** the draft PR establishes ownership and `/claim` marks the issue as taken.
|
||||
Reference `#N` in the PR so the bot can find it; a comment alone does not replace the draft PR.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim.
|
||||
- Mark the PR ready for review when it is done.
|
||||
@@ -267,10 +270,11 @@ and a fresh CI run:
|
||||
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
|
||||
the expected ones).
|
||||
|
||||
Comment `/claim` on an issue to take it: you are assigned, it gets the `in progress` label, and
|
||||
the bot replies with the deadline. Open a draft PR referencing the issue within 72 hours, or the
|
||||
claim lapses and the issue is freed again; a claim with a PR is freed after 14 days without
|
||||
pushes. `/unclaim` releases it early (only you or a maintainer can). Issues a maintainer has
|
||||
The `/claim` bot assigns you, adds the `in progress` label, and replies with its automatic cleanup
|
||||
deadline. It frees a claim with no linked PR after 72 hours, or a claim with a PR after 14 days
|
||||
without pushes. These automatic cleanup deadlines are separate from the daily progress and
|
||||
72-hour takeover policy above; follow the draft PR then `/claim` sequence before starting work.
|
||||
`/unclaim` releases the bot claim early (only you or a maintainer can). Issues a maintainer has
|
||||
taken are never freed automatically.
|
||||
|
||||
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
|
||||
|
||||
@@ -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",
|
||||
@@ -1367,6 +1369,8 @@
|
||||
"ui.reset_the_settings_on_this_page": "Reset the settings on this page",
|
||||
"ui.restore": "Restore",
|
||||
"ui.restore_defaults": "Restore Defaults",
|
||||
"ui.reverse_horizontal_pan_direction": "Reverse horizontal pan direction",
|
||||
"ui.reverse_horizontal_pan_tip": "Horizontal two-finger scrolling and the thumb wheel pan the other way",
|
||||
"ui.reverse_zoom_direction": "Reverse zoom direction",
|
||||
"ui.revolve": "Revolve",
|
||||
"ui.right": "Right",
|
||||
|
||||
+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": "現在の透視投影",
|
||||
@@ -1367,6 +1369,8 @@
|
||||
"ui.reset_the_settings_on_this_page": "このページの設定をリセット",
|
||||
"ui.restore": "復元",
|
||||
"ui.restore_defaults": "初期値に戻す",
|
||||
"ui.reverse_horizontal_pan_direction": "水平パンの方向を反転",
|
||||
"ui.reverse_horizontal_pan_tip": "水平方向の2本指スクロールとサムホイールで、反対方向にパンします",
|
||||
"ui.reverse_zoom_direction": "ズーム方向を反転",
|
||||
"ui.revolve": "回転",
|
||||
"ui.right": "右側面",
|
||||
|
||||
@@ -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": "当前透视视图",
|
||||
@@ -1367,6 +1369,8 @@
|
||||
"ui.reset_the_settings_on_this_page": "重置此页面上的设置",
|
||||
"ui.restore": "还原",
|
||||
"ui.restore_defaults": "恢复默认值",
|
||||
"ui.reverse_horizontal_pan_direction": "反转水平平移方向",
|
||||
"ui.reverse_horizontal_pan_tip": "水平双指滚动和拇指滚轮向相反方向平移",
|
||||
"ui.reverse_zoom_direction": "反转缩放方向",
|
||||
"ui.revolve": "旋转",
|
||||
"ui.right": "右视",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
@@ -4691,6 +4705,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::SurfaceLoft { .. }
|
||||
| FeatureKind::SurfaceBlend { .. }
|
||||
| FeatureKind::SurfaceBoundary { .. }
|
||||
| FeatureKind::SurfaceNetwork { .. }
|
||||
| FeatureKind::SurfaceMatch { .. }
|
||||
| FeatureKind::SurfaceFair { .. }
|
||||
| FeatureKind::SurfaceOffset { .. }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,6 +148,7 @@ pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fill
|
||||
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
|
||||
pub use boundary::{Boundary, boundary_patch};
|
||||
pub use boundary_network::{BoundaryCurve, CurveSource, MAX_CHAIN_CURVES, MAX_EDGE_PICKS, boundary_join, boundary_surface};
|
||||
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
|
||||
pub use build::{
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()])
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+26
-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,
|
||||
@@ -431,6 +434,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(),
|
||||
@@ -625,6 +629,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 +733,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 +758,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 +792,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 +1167,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 +1851,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).
|
||||
|
||||
@@ -152,7 +152,7 @@ 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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,6 +12,7 @@ python3 tools/oracle-analytic/cases_basic.py # 01-29, 82: boxes, holes, r
|
||||
python3 tools/oracle-analytic/cases_features.py # 32-66: fillets, shells, sweeps, patterns
|
||||
python3 tools/oracle-analytic/cases_sheet_metal.py # 67-76: sheet metal
|
||||
python3 tools/oracle-analytic/cases_fillets.py # 77-81: variable, chord-length, tapered and spline fillets
|
||||
python3 tools/oracle-analytic/cases_surfaces.py # 83: boundary surfaces
|
||||
```
|
||||
|
||||
Where a value depends on a SolidCraft convention rather than on geometry alone (a shell's sharp
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Closed-form expected values for the surface oracle cases.
|
||||
|
||||
Run from the worktree root: python3 tools/oracle-analytic/cases_surfaces.py [case ...]
|
||||
Writes tests/oracle/<case>/expected.json with "source": "analytic".
|
||||
|
||||
Every value is derived from first principles (areas and centroids of plane regions bounded by
|
||||
lines and circular arcs, a slab of constant thickness over such a region). Nothing here is
|
||||
measured from a CAD kernel. Stdlib only.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
PI = math.pi
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "tests", "oracle")
|
||||
|
||||
|
||||
def c83_boundary_loop_thicken():
|
||||
"""A boundary surface through four sketch curves on XY (the 10 x 10 square's bottom, right and
|
||||
left sides and, for its top, a half circle of radius 5 bulging out to y = 15) is the flat
|
||||
region they bound; thickened 2 mm symmetrically it is a slab from z = -1 to z = 1."""
|
||||
side, r, t = 10.0, 5.0, 2.0
|
||||
sq_a, sq_c = side * side, (5.0, 5.0)
|
||||
half_a, half_c = PI * r * r / 2, (5.0, side + 4 * r / (3 * PI))
|
||||
area = sq_a + half_a
|
||||
cx = (sq_a * sq_c[0] + half_a * half_c[0]) / area
|
||||
cy = (sq_a * sq_c[1] + half_a * half_c[1]) / area
|
||||
perimeter = 3 * side + PI * r
|
||||
return {
|
||||
"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": t * area,
|
||||
"area_mm2": 2 * area + t * perimeter,
|
||||
"center_of_mass": [cx, cy, 0.0],
|
||||
"bbox": {"min": [0.0, 0.0, -t / 2], "max": [side, side + r, t / 2]},
|
||||
"faces": 6,
|
||||
}
|
||||
|
||||
|
||||
CASES = {
|
||||
"83-boundary-loop-thicken": c83_boundary_loop_thicken,
|
||||
}
|
||||
|
||||
|
||||
def main(argv):
|
||||
names = argv[1:] or list(CASES)
|
||||
for n in names:
|
||||
d = CASES[n]()
|
||||
path = os.path.join(ROOT, n, "expected.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(d, f, indent=2)
|
||||
f.write("\n")
|
||||
print(f"{n}: V={d['volume_mm3']!r} A={d['area_mm2']!r} com={d['center_of_mass']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv)
|
||||
Vendored
+4
@@ -59,6 +59,10 @@ Changes:
|
||||
back to the seam at the other end of the period and fold its parameter polygon over itself (no
|
||||
intersection loop then lay inside the face). `crates/kernel/src/seam_faces_tests.rs` holds it
|
||||
to a cone read from STEP.
|
||||
- `nurbs/bspsurface.rs`: `uuder` and `vvder` multiply by p·(p − 1), as `BSplineCurve::der2`
|
||||
does. They multiplied by p only, so the second derivatives of every B-spline and NURBS surface
|
||||
of degree p ≥ 3 were 1/(p − 1) of their value (a cubic surface's curvature read as half, #763).
|
||||
`crates/kernel/src/surface_second_derivative_tests.rs` holds them to finite differences.
|
||||
|
||||
## truck-stepio 0.3.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/truck)
|
||||
|
||||
|
||||
@@ -729,11 +729,15 @@ impl<P: ControlPoint<f64>> ParametricSurface for BSplineSurface<P> {
|
||||
};
|
||||
basis1.iter().enumerate().fold(sum, closure)
|
||||
};
|
||||
// SolidCraft: the second derivative of a degree-p spline is p·(p − 1) times the sum of the
|
||||
// second differences against the degree p − 2 basis (as `BSplineCurve::der2` has it). The
|
||||
// factor p − 1 was missing, which halved the second derivative of a cubic surface.
|
||||
basis0
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.fold(P::Diff::zero(), closure)
|
||||
* degree0 as f64
|
||||
* (degree0 - 1) as f64
|
||||
}
|
||||
|
||||
/// Substitutes 2nd-ord derived B-spline surface by the second parameter `v`.
|
||||
@@ -778,11 +782,13 @@ impl<P: ControlPoint<f64>> ParametricSurface for BSplineSurface<P> {
|
||||
};
|
||||
basis0.iter().enumerate().fold(sum, closure)
|
||||
};
|
||||
// SolidCraft: times p·(p − 1), as in `uuder`.
|
||||
basis1
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.fold(P::Diff::zero(), closure)
|
||||
* degree1 as f64
|
||||
* (degree1 - 1) as f64
|
||||
}
|
||||
|
||||
/// Substitutes 2nd-ord derived B-spline surface by the both parameters `u, v`.
|
||||
|
||||
Reference in new issue
Block a user