mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-12 03:31:57 +00:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c5292285a | |||
| d806707b8f | |||
| c524fcc6c9 | |||
| 7001949c49 | |||
| 777f960999 | |||
| d4f923a3e6 | |||
| bbb98928fb | |||
| 8e2b0cd60d | |||
| afd81380ab | |||
| 3a5195b0a0 | |||
| ee6b85605a | |||
| aa1fe28ddf | |||
| 5b0eb08473 | |||
| 9df8837d43 | |||
| 409ab1ed45 | |||
| 5e03fe2f4b | |||
| 37a586dd61 | |||
| a79f40a711 | |||
| b5728f284b | |||
| 5965439a15 | |||
| 586a358ed4 | |||
| 7770f8982d | |||
| 72ad6383ef | |||
| 0537623a75 | |||
| 65d288227e | |||
| fedb3bcdb1 | |||
| 097b8b7550 | |||
| af7ca3a8a2 | |||
| 27ddb725c7 | |||
| a06bdf5cfc | |||
| 33fdcdb3a0 | |||
| c07ab5f8f9 | |||
| fea32f8aac | |||
| 4927214976 | |||
| b621819c0c | |||
| ec552a5300 | |||
| e0cc740cc3 | |||
| 5de48e916d | |||
| 80af7bbe3e | |||
| 191e983423 | |||
| 2b9445b049 | |||
| 7ddf48ca16 | |||
| d2f6848377 | |||
| 0b6d6fecd8 | |||
| fa691707c2 | |||
| b9a55c74e2 | |||
| 2bb9c81069 | |||
| cedb51b8ea | |||
| 66d13059dd | |||
| 800ea40c0d | |||
| 20442eecd3 | |||
| 22d1cd7032 | |||
| 6a0dc18655 | |||
| e006dbf1af | |||
| 8241a750fa | |||
| 41779cf7bb | |||
| b01b9689c8 | |||
| 4426da6f5d | |||
| 5ead82e627 | |||
| c29b0d2f13 | |||
| 480fd8d517 | |||
| 4c09fa5355 |
@@ -9,9 +9,3 @@ self-hosted-runner:
|
||||
- blacksmith-16vcpu-ubuntu-2404
|
||||
- blacksmith-32vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-windows-2025
|
||||
# Release builds (workflows/release.yml).
|
||||
- blacksmith-16vcpu-ubuntu-2204
|
||||
- blacksmith-16vcpu-ubuntu-2204-arm
|
||||
- blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- blacksmith-6vcpu-macos-15
|
||||
- blacksmith-12vcpu-macos-15
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
# The macOS app is signed and notarized when the APPLE_* secrets exist. The Windows binaries and
|
||||
# MSI are Authenticode-signed through SignPath Foundation when the SIGNPATH_API_TOKEN secret exists
|
||||
# (see the windows job). Nothing else is signed yet.
|
||||
# Runners: Blacksmith (blacksmith-<N>vcpu-<os>), as CI uses, except the `version` and `windows`
|
||||
# jobs, which stay GitHub-hosted: SignPath Foundation only signs a request when every job leading
|
||||
# up to it ran on a GitHub-hosted runner.
|
||||
#
|
||||
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
|
||||
# Arch Linux x86_64, aarch64 .pkg.tar.zst, repackaged from the .tar.gz the arch job
|
||||
@@ -117,18 +114,40 @@ jobs:
|
||||
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
|
||||
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
|
||||
icons:
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-icons
|
||||
- run: cargo build --release --locked -p solidcraft-cli
|
||||
- run: |
|
||||
. packaging/env.sh
|
||||
make_icons target/release/solidcraft-cli icons
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# Ubuntu 22.04, the oldest image, so the binaries only need glibc >= 2.35.
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: blacksmith-16vcpu-ubuntu-2204, deb: amd64 }
|
||||
- { arch: aarch64, runner: blacksmith-16vcpu-ubuntu-2204-arm, deb: arm64 }
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -184,8 +203,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
|
||||
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -214,7 +233,7 @@ jobs:
|
||||
needs: [version, linux]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# makepkg only repackages the tarball's prebuilt binaries, so both architectures build here.
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
# A pre-release or nightly without an Arch package is still worth having; a stable release isn't.
|
||||
continue-on-error: ${{ contains(needs.version.outputs.version, '-') }}
|
||||
strategy:
|
||||
@@ -284,8 +303,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
|
||||
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -330,8 +349,8 @@ jobs:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# There are no FreeBSD runners: build in a FreeBSD 14.3 VM (Blacksmith's x64 runners have KVM).
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
@@ -344,8 +363,8 @@ jobs:
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 14
|
||||
mem: 40960
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
|
||||
prepare: |
|
||||
@@ -355,9 +374,9 @@ jobs:
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM. About 3 GB of
|
||||
# VM memory per job: 4 jobs in 12 GB was the most that fit before.
|
||||
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=12
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
|
||||
# runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/io\.tangencylabs\.solidcraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
@@ -369,12 +388,11 @@ jobs:
|
||||
|
||||
# The two halves of the universal binary build in parallel on their own runners (each alone
|
||||
# took about half of the old single job's 45 minutes); `macos` below joins and packages them.
|
||||
# Blacksmith's macOS runners are Apple silicon: the x86_64 half is cross-compiled.
|
||||
macos-build:
|
||||
name: macOS build ${{ matrix.arch }}
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: blacksmith-12vcpu-macos-15
|
||||
runs-on: macos-15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -408,7 +426,7 @@ jobs:
|
||||
name: macOS universal
|
||||
needs: [version, macos-build]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
runs-on: macos-15
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -448,11 +466,10 @@ jobs:
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
needs: [version, icons]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# Must stay a GitHub-hosted runner, like the `version` job before it: SignPath's open-source
|
||||
# policy only signs requests whose jobs leading up to them all ran on GitHub-hosted runners.
|
||||
# The installer icon is the committed assets/icons/app/windows/exports/solidcraft.ico.
|
||||
# Must stay a GitHub-hosted runner, like every job before it: SignPath's open-source policy
|
||||
# only signs builds whose jobs all ran on GitHub-hosted runners.
|
||||
runs-on: windows-latest
|
||||
# actions: read lets the SignPath action read this job's details and fetch the uploaded
|
||||
# artifact with GITHUB_TOKEN (the workflow default above grants contents: read only).
|
||||
@@ -481,6 +498,10 @@ jobs:
|
||||
SIGNPATH_PROJECT_SLUG: solidcraft
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -495,6 +516,8 @@ jobs:
|
||||
- name: Build and package (msi, portable zip)
|
||||
if: env.SIGN_WINDOWS != 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
|
||||
# ---- signed path ---------------------------------------------------------------------------
|
||||
@@ -508,6 +531,8 @@ jobs:
|
||||
- name: Build and stage the binaries for signing
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase stage
|
||||
- name: Upload the unsigned binaries
|
||||
id: unsigned-exes
|
||||
@@ -551,6 +576,8 @@ jobs:
|
||||
- name: Package the signed binaries (msi, portable zip)
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase package
|
||||
- name: Upload the unsigned MSI
|
||||
id: unsigned-msi
|
||||
@@ -600,7 +627,7 @@ jobs:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
@@ -629,7 +656,7 @@ jobs:
|
||||
needs: [version, linux, flatpak, arch, snap, freebsd, macos, windows, web]
|
||||
# Not for the release gate (packages_only): it builds and checks the packages, nothing more.
|
||||
if: needs.version.outputs.build == 'true' && !inputs.packages_only
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "الملفات المستوردة",
|
||||
"prefs.design_tools.note": "ملاحظة",
|
||||
"prefs.design_tools.off": "إيقاف",
|
||||
"prefs.design_tools.placement_group": "الميزات الموضوعة",
|
||||
"prefs.design_tools.placement_group": "مسند، نتوء، تعشيق مرن",
|
||||
"prefs.design_tools.rows": "صفوف",
|
||||
"prefs.devices": "الفأرة ثلاثية الأبعاد",
|
||||
"prefs.dialog_buttons": "أزرار مربعات الحوار",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "الخطوة",
|
||||
"ui.placement": "الموضع",
|
||||
"ui.placement_handles": "مقابض الموضع",
|
||||
"ui.placement_handles_tip": "علامة لسحب الميزة عبر النموذج، وحلقة لتدويرها حيث يمكن تدوير الميزة.",
|
||||
"ui.placement_handles_tip": "علامة لسحب الميزة عبر النموذج وحلقة لتدويرها.",
|
||||
"ui.plane": "المستوى",
|
||||
"ui.plane_at_angle": "مستوى بزاوية",
|
||||
"ui.plugins": "الإضافات",
|
||||
|
||||
@@ -3384,7 +3384,7 @@
|
||||
"ui.pitch": "Pitch",
|
||||
"ui.placement": "Placement",
|
||||
"ui.placement_handles": "Placement handles",
|
||||
"ui.placement_handles_tip": "Маркер за влачене на елемента по модела и, където елементът може да се завърта, пръстен за завъртането му.",
|
||||
"ui.placement_handles_tip": "Handles on the preview of a Rest, Boss or Snap Fit: drag the marker to move it across the model, turn it by its ring",
|
||||
"ui.plane": "Plane",
|
||||
"ui.plane_at_angle": "Plane at Angle",
|
||||
"ui.plugins": "Plugins",
|
||||
|
||||
@@ -4907,7 +4907,7 @@
|
||||
"ui.pitch": "Korak",
|
||||
"ui.placement": "Postavljanje",
|
||||
"ui.placement_handles": "Ručice za postavljanje",
|
||||
"ui.placement_handles_tip": "Oznaka za povlačenje operacije po modelu i, gdje se operacija može okretati, prsten za njeno okretanje.",
|
||||
"ui.placement_handles_tip": "Ručice na pregledu oslonca, ispusta ili škljocajućeg spoja: povucite oznaku da je pomjerite po modelu, okrenite je njenim prstenom",
|
||||
"ui.plane": "Ravan",
|
||||
"ui.plane_at_angle": "Ravan pod uglom",
|
||||
"ui.plugins": "Proširenja",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "Fitxers importats",
|
||||
"prefs.design_tools.note": "Nota",
|
||||
"prefs.design_tools.off": "Desactivat",
|
||||
"prefs.design_tools.placement_group": "Operacions col·locades",
|
||||
"prefs.design_tools.placement_group": "Repòs, Resalt, Encaix",
|
||||
"prefs.design_tools.rows": "Files",
|
||||
"prefs.devices": "Ratolí 3D",
|
||||
"prefs.dialog_buttons": "Botons de diàleg",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "Inclinació",
|
||||
"ui.placement": "Col·locació",
|
||||
"ui.placement_handles": "Tiradors de col·locació",
|
||||
"ui.placement_handles_tip": "Un marcador per arrossegar l'operació pel model i, on l'operació es pot girar, un anell per girar-la.",
|
||||
"ui.placement_handles_tip": "Un marcador per arrossegar l'operació pel model i un anell per girar-la.",
|
||||
"ui.plane": "Pla",
|
||||
"ui.plane_at_angle": "Pla amb angle",
|
||||
"ui.plugins": "Complements",
|
||||
|
||||
@@ -2562,7 +2562,7 @@
|
||||
"prefs.design_tools.imported_files": "Importované soubory",
|
||||
"prefs.design_tools.note": "Poznámka",
|
||||
"prefs.design_tools.off": "Vypnuto",
|
||||
"prefs.design_tools.placement_group": "Umisťované prvky",
|
||||
"prefs.design_tools.placement_group": "Opěra, Nálitek, Západka",
|
||||
"prefs.design_tools.rows": "Řádky",
|
||||
"prefs.devices": "3D myš",
|
||||
"prefs.dialog_buttons": "Tlačítka dialogu",
|
||||
@@ -5824,7 +5824,7 @@
|
||||
"ui.pitch": "Stoupání",
|
||||
"ui.placement": "Umístění",
|
||||
"ui.placement_handles": "Úchyty umístění",
|
||||
"ui.placement_handles_tip": "Značka pro přetažení prvku po modelu a tam, kde se prvek může otáčet, kroužek pro jeho otočení.",
|
||||
"ui.placement_handles_tip": "Úchyty na náhledu opěry, nálitku nebo západky: tažením značky ji přesunete po modelu, prstencem ji otočíte",
|
||||
"ui.plane": "Rovina",
|
||||
"ui.plane_at_angle": "Rovina pod úhlem",
|
||||
"ui.plugins": "Moduly plug-in",
|
||||
|
||||
@@ -4916,7 +4916,7 @@
|
||||
"ui.pitch": "Stigning",
|
||||
"ui.placement": "Placering",
|
||||
"ui.placement_handles": "Placeringshåndtag",
|
||||
"ui.placement_handles_tip": "En markør til at trække featuren hen over modellen og, hvor featuren kan drejes, en ring til at dreje den.",
|
||||
"ui.placement_handles_tip": "Håndtag på forhåndsvisningen af en Understøtning, Skruebøsning eller Snapforbindelse: Træk i markøren for at flytte den hen over modellen, og drej den ved dens ring",
|
||||
"ui.plane": "Plan",
|
||||
"ui.plane_at_angle": "Plan i vinkel",
|
||||
"ui.plugins": "Plugins",
|
||||
|
||||
@@ -2424,7 +2424,7 @@
|
||||
"prefs.design_tools.imported_files": "Importierte Dateien",
|
||||
"prefs.design_tools.note": "Hinweis",
|
||||
"prefs.design_tools.off": "Aus",
|
||||
"prefs.design_tools.placement_group": "Platzierte Funktionen",
|
||||
"prefs.design_tools.placement_group": "Auflage, Zapfen, Schnappverbindung",
|
||||
"prefs.design_tools.rows": "Zeilen",
|
||||
"prefs.devices": "3D-Maus",
|
||||
"prefs.dialog_buttons": "Dialogschaltflächen",
|
||||
@@ -5758,7 +5758,7 @@
|
||||
"ui.pitch": "Steigung",
|
||||
"ui.placement": "Platzierung",
|
||||
"ui.placement_handles": "Platzierungsgriffe",
|
||||
"ui.placement_handles_tip": "Eine Markierung zum Verschieben des Features über das Modell und, wo sich das Feature drehen lässt, ein Ring zum Drehen.",
|
||||
"ui.placement_handles_tip": "Eine Markierung zum Verschieben des Features über das Modell und ein Ring zum Drehen.",
|
||||
"ui.plane": "Ebene",
|
||||
"ui.plane_at_angle": "Ebene im Winkel",
|
||||
"ui.plugins": "Plugins",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "Imported files",
|
||||
"prefs.design_tools.note": "Note",
|
||||
"prefs.design_tools.off": "Off",
|
||||
"prefs.design_tools.placement_group": "Placed features",
|
||||
"prefs.design_tools.placement_group": "Rest, Boss, Snap Fit",
|
||||
"prefs.design_tools.rows": "Rows",
|
||||
"prefs.devices": "3D mouse",
|
||||
"prefs.dialog_buttons": "Dialog buttons",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "Pitch",
|
||||
"ui.placement": "Placement",
|
||||
"ui.placement_handles": "Placement handles",
|
||||
"ui.placement_handles_tip": "A marker to drag the feature across the model and, where the feature can turn, a ring to turn it.",
|
||||
"ui.placement_handles_tip": "A marker to drag the feature across the model and a ring to turn it.",
|
||||
"ui.plane": "Plane",
|
||||
"ui.plane_at_angle": "Plane at Angle",
|
||||
"ui.plugins": "Plugins",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "Archivos importados",
|
||||
"prefs.design_tools.note": "Nota",
|
||||
"prefs.design_tools.off": "Desactivado",
|
||||
"prefs.design_tools.placement_group": "Operaciones colocadas",
|
||||
"prefs.design_tools.placement_group": "Apoyo, Saliente, Encaje",
|
||||
"prefs.design_tools.rows": "Filas",
|
||||
"prefs.devices": "Ratón 3D",
|
||||
"prefs.dialog_buttons": "Botones de diálogo",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "Inclinación",
|
||||
"ui.placement": "Colocación",
|
||||
"ui.placement_handles": "Tiradores de colocación",
|
||||
"ui.placement_handles_tip": "Un marcador para arrastrar la operación por el modelo y, donde la operación puede girar, un anillo para girarla.",
|
||||
"ui.placement_handles_tip": "Tiradores en la vista previa de un Refuerzo, Saliente o Encaje a presión: arrastra el marcador para moverlo por el modelo, gíralo por su anillo.",
|
||||
"ui.plane": "Plano",
|
||||
"ui.plane_at_angle": "Plano en ángulo",
|
||||
"ui.plugins": "Complementos",
|
||||
|
||||
@@ -2581,7 +2581,7 @@
|
||||
"prefs.design_tools.imported_files": "Tuodut tiedostot",
|
||||
"prefs.design_tools.note": "Huomautus",
|
||||
"prefs.design_tools.off": "Pois",
|
||||
"prefs.design_tools.placement_group": "Sijoitettavat piirteet",
|
||||
"prefs.design_tools.placement_group": "Tuki, Tappi, Napsautusliitos",
|
||||
"prefs.design_tools.rows": "Rivit",
|
||||
"prefs.devices": "3D-hiiri",
|
||||
"prefs.dialog_buttons": "Valintaikkunan painikkeet",
|
||||
@@ -5850,7 +5850,7 @@
|
||||
"ui.pitch": "Nousu",
|
||||
"ui.placement": "Sijoitus",
|
||||
"ui.placement_handles": "Sijoituskahvat",
|
||||
"ui.placement_handles_tip": "Merkki piirteen vetämiseen mallin yli ja, jos piirrettä voi kääntää, rengas sen kääntämiseen.",
|
||||
"ui.placement_handles_tip": "Kahvat tuen, tapin tai napsautusliitoksen esikatselussa: siirrä merkkiä mallin pinnalla vetämällä ja käännä sitä renkaasta",
|
||||
"ui.plane": "Taso",
|
||||
"ui.plane_at_angle": "Taso kulmassa",
|
||||
"ui.plugins": "Laajennukset",
|
||||
|
||||
@@ -2343,7 +2343,7 @@
|
||||
"prefs.design_tools.imported_files": "Fichiers importés",
|
||||
"prefs.design_tools.note": "Remarque",
|
||||
"prefs.design_tools.off": "Désactivé",
|
||||
"prefs.design_tools.placement_group": "Fonctions placées",
|
||||
"prefs.design_tools.placement_group": "Appui, Bossage, Clip",
|
||||
"prefs.design_tools.rows": "Lignes",
|
||||
"prefs.devices": "Souris 3D",
|
||||
"prefs.dialog_buttons": "Boutons OK",
|
||||
@@ -5287,7 +5287,7 @@
|
||||
"ui.pitch": "Pas",
|
||||
"ui.placement": "Placement",
|
||||
"ui.placement_handles": "Poignées de placement",
|
||||
"ui.placement_handles_tip": "Un repère pour faire glisser la fonction sur le modèle et, là où la fonction peut pivoter, un anneau pour la faire pivoter.",
|
||||
"ui.placement_handles_tip": "Poignées sur l'aperçu d'un appui, d'un bossage ou d'un clip : faire glisser le repère pour le déplacer sur le modèle, le tourner par son anneau",
|
||||
"ui.plane": "Plan",
|
||||
"ui.plane_at_angle": "Plan incliné",
|
||||
"ui.plugins": "Modules externes",
|
||||
|
||||
@@ -4915,7 +4915,7 @@
|
||||
"ui.pitch": "Paso",
|
||||
"ui.placement": "Colocación",
|
||||
"ui.placement_handles": "Asas de colocación",
|
||||
"ui.placement_handles_tip": "Un marcador para arrastrar a operación polo modelo e, onde a operación pode xirar, un anel para xirala.",
|
||||
"ui.placement_handles_tip": "Asas na vista previa dun Apoio, un Saínte ou un Encaixe a presión: arrastra o marcador para movelo polo modelo, xírao polo seu anel",
|
||||
"ui.plane": "Plano",
|
||||
"ui.plane_at_angle": "Plano en ángulo",
|
||||
"ui.plugins": "Complementos",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "קבצים מיובאים",
|
||||
"prefs.design_tools.note": "הערה",
|
||||
"prefs.design_tools.off": "כבוי",
|
||||
"prefs.design_tools.placement_group": "פיצ'רים ממוקמים",
|
||||
"prefs.design_tools.placement_group": "משען, בליטה, חיבור נצמד",
|
||||
"prefs.design_tools.rows": "שורות",
|
||||
"prefs.devices": "עכבר תלת־ממדי",
|
||||
"prefs.dialog_buttons": "לחצני תיבות דו־שיח",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "פסיעה",
|
||||
"ui.placement": "מיקום",
|
||||
"ui.placement_handles": "ידיות מיקום",
|
||||
"ui.placement_handles_tip": "סמן לגרירת התכונה על המודל, וטבעת לסיבובה במקום שבו אפשר לסובב את התכונה.",
|
||||
"ui.placement_handles_tip": "סמן לגרירת התכונה על המודל וטבעת לסיבובה.",
|
||||
"ui.plane": "מישור",
|
||||
"ui.plane_at_angle": "מישור בזווית",
|
||||
"ui.plugins": "תוספים",
|
||||
|
||||
@@ -4907,7 +4907,7 @@
|
||||
"ui.pitch": "Korak",
|
||||
"ui.placement": "Postavljanje",
|
||||
"ui.placement_handles": "Ručice za postavljanje",
|
||||
"ui.placement_handles_tip": "Oznaka za povlačenje značajke po modelu i, gdje se značajka može okretati, prsten za njezino okretanje.",
|
||||
"ui.placement_handles_tip": "Ručice na pregledu oslonca, ispusta ili škljocajućeg spoja: povucite oznaku kako biste je pomaknuli po modelu, okrenite je njezinim prstenom",
|
||||
"ui.plane": "Ravnina",
|
||||
"ui.plane_at_angle": "Ravnina pod kutom",
|
||||
"ui.plugins": "Proširenja",
|
||||
|
||||
@@ -4915,7 +4915,7 @@
|
||||
"ui.pitch": "Emelkedés",
|
||||
"ui.placement": "Elhelyezés",
|
||||
"ui.placement_handles": "Elhelyezési fogantyúk",
|
||||
"ui.placement_handles_tip": "Egy jelölő az elem modellen való húzásához és, ahol az elem elforgatható, egy gyűrű az elforgatásához.",
|
||||
"ui.placement_handles_tip": "Fogantyúk egy Támasz, Dudor vagy Pattintós illesztés előnézetén: húzza a jelölőt a modell fölött, a gyűrűjénél fogva forgassa el",
|
||||
"ui.plane": "Sík",
|
||||
"ui.plane_at_angle": "Szögben álló sík",
|
||||
"ui.plugins": "Bővítmények",
|
||||
|
||||
@@ -4915,7 +4915,7 @@
|
||||
"ui.pitch": "Pitch",
|
||||
"ui.placement": "Penempatan",
|
||||
"ui.placement_handles": "Handle penempatan",
|
||||
"ui.placement_handles_tip": "Penanda untuk menyeret fitur di atas model dan, jika fitur dapat diputar, cincin untuk memutarnya.",
|
||||
"ui.placement_handles_tip": "Handle pada pratinjau Rest, Boss, atau Snap Fit: seret penanda untuk memindahkannya di model, putar lewat cincinnya",
|
||||
"ui.plane": "Bidang",
|
||||
"ui.plane_at_angle": "Bidang Bersudut",
|
||||
"ui.plugins": "Plugin",
|
||||
|
||||
@@ -2588,7 +2588,7 @@
|
||||
"prefs.design_tools.imported_files": "File importati",
|
||||
"prefs.design_tools.note": "Avvisa",
|
||||
"prefs.design_tools.off": "Disattivato",
|
||||
"prefs.design_tools.placement_group": "Lavorazioni posizionate",
|
||||
"prefs.design_tools.placement_group": "Appoggio, Risalto, Aggancio a scatto",
|
||||
"prefs.design_tools.rows": "Righe",
|
||||
"prefs.devices": "Mouse 3D",
|
||||
"prefs.dialog_buttons": "Pulsanti OK",
|
||||
@@ -5873,7 +5873,7 @@
|
||||
"ui.pitch": "Passo",
|
||||
"ui.placement": "Posizionamento",
|
||||
"ui.placement_handles": "Maniglie di posizionamento",
|
||||
"ui.placement_handles_tip": "Un indicatore per trascinare la lavorazione sul modello e, dove la lavorazione può ruotare, un anello per ruotarla.",
|
||||
"ui.placement_handles_tip": "Maniglie sull'anteprima di un Appoggio, Risalto o Aggancio a scatto: trascina il marcatore per spostarlo sul modello, ruotalo dal suo anello",
|
||||
"ui.plane": "Piano",
|
||||
"ui.plane_at_angle": "Piano inclinato",
|
||||
"ui.plugins": "Plugin",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "インポートしたファイル",
|
||||
"prefs.design_tools.note": "通知",
|
||||
"prefs.design_tools.off": "オフ",
|
||||
"prefs.design_tools.placement_group": "配置するフィーチャー",
|
||||
"prefs.design_tools.placement_group": "レスト、ボス、スナップフィット",
|
||||
"prefs.design_tools.rows": "行数",
|
||||
"prefs.devices": "3D マウス",
|
||||
"prefs.dialog_buttons": "ダイアログのボタン",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "ピッチ",
|
||||
"ui.placement": "配置",
|
||||
"ui.placement_handles": "配置ハンドル",
|
||||
"ui.placement_handles_tip": "モデル上でフィーチャーをドラッグするマーカーと、フィーチャーを回転できる場合は回転させるリング。",
|
||||
"ui.placement_handles_tip": "モデル上でフィーチャーをドラッグするマーカーと、回転させるリング。",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "傾斜平面",
|
||||
"ui.plugins": "プラグイン",
|
||||
|
||||
@@ -5073,7 +5073,7 @@
|
||||
"ui.pitch": "ბიჯი",
|
||||
"ui.placement": "განთავსება",
|
||||
"ui.placement_handles": "განთავსების სახელურები",
|
||||
"ui.placement_handles_tip": "მარკერი ოპერაციის მოდელზე გადასატანად და, სადაც ოპერაციის მობრუნება შეიძლება, რგოლი მის მოსაბრუნებლად.",
|
||||
"ui.placement_handles_tip": "სახელურები საყრდენის, ბოსის ან ჩამკეტის გადახედვაზე: მარკერი გადაათრიეთ მოდელზე მის გადასაადგილებლად, ხოლო რგოლით შეაბრუნეთ",
|
||||
"ui.plane": "სიბრტყე",
|
||||
"ui.plane_at_angle": "სიბრტყე კუთხით",
|
||||
"ui.plugins": "პლაგინები",
|
||||
|
||||
@@ -1996,7 +1996,7 @@
|
||||
"prefs.design_tools.imported_files": "가져온 파일",
|
||||
"prefs.design_tools.note": "알림",
|
||||
"prefs.design_tools.off": "끄기",
|
||||
"prefs.design_tools.placement_group": "배치 피처",
|
||||
"prefs.design_tools.placement_group": "레스트, 보스, 스냅 핏",
|
||||
"prefs.design_tools.rows": "행",
|
||||
"prefs.devices": "3D 마우스",
|
||||
"prefs.dialog_buttons": "대화 상자 버튼",
|
||||
@@ -5195,7 +5195,7 @@
|
||||
"ui.pipe": "파이프",
|
||||
"ui.pitch": "피치",
|
||||
"ui.placement": "배치",
|
||||
"ui.placement_handles_tip": "모델 위로 피처를 드래그하는 마커와, 피처를 회전할 수 있는 경우 회전하는 링입니다.",
|
||||
"ui.placement_handles_tip": "모델 위로 피처를 드래그하는 마커와 회전하는 링입니다.",
|
||||
"ui.plane": "평면",
|
||||
"ui.plane_at_angle": "각도 평면",
|
||||
"ui.plugins": "플러그인",
|
||||
|
||||
@@ -2582,7 +2582,7 @@
|
||||
"prefs.design_tools.imported_files": "Importētie faili",
|
||||
"prefs.design_tools.note": "Piezīme",
|
||||
"prefs.design_tools.off": "Izslēgts",
|
||||
"prefs.design_tools.placement_group": "Izvietojamās operācijas",
|
||||
"prefs.design_tools.placement_group": "Atbalsts, izcilnis, aizspiedsavienojums",
|
||||
"prefs.design_tools.rows": "Rindas",
|
||||
"prefs.devices": "3D pele",
|
||||
"prefs.dialog_buttons": "Dialoglodziņu pogas",
|
||||
@@ -5903,7 +5903,7 @@
|
||||
"ui.pitch": "Solis",
|
||||
"ui.placement": "Izvietojums",
|
||||
"ui.placement_handles": "Izvietošanas turi",
|
||||
"ui.placement_handles_tip": "Marķieris operācijas vilkšanai pa modeli un, kur operāciju var pagriezt, gredzens tās pagriešanai.",
|
||||
"ui.placement_handles_tip": "Marķieris operācijas vilkšanai pa modeli un gredzens tās pagriešanai.",
|
||||
"ui.plane": "Plakne",
|
||||
"ui.plane_at_angle": "Plakne leņķī",
|
||||
"ui.plugins": "Spraudņi",
|
||||
|
||||
@@ -4915,7 +4915,7 @@
|
||||
"ui.pitch": "Pic",
|
||||
"ui.placement": "Penempatan",
|
||||
"ui.placement_handles": "Pemegang penempatan",
|
||||
"ui.placement_handles_tip": "Penanda untuk menyeret ciri merentasi model dan, jika ciri boleh dipusingkan, gelang untuk memusingkannya.",
|
||||
"ui.placement_handles_tip": "Pemegang pada pratonton Penyokong, Bonggol atau Sambungan Sentap: seret penanda untuk menggerakkannya merentasi model, putarkannya melalui cincinnya",
|
||||
"ui.plane": "Satah",
|
||||
"ui.plane_at_angle": "Satah Bersudut",
|
||||
"ui.plugins": "Pemalam",
|
||||
|
||||
@@ -4915,7 +4915,7 @@
|
||||
"ui.pitch": "Stigning",
|
||||
"ui.placement": "Plassering",
|
||||
"ui.placement_handles": "Plasseringshåndtak",
|
||||
"ui.placement_handles_tip": "En markør for å dra funksjonen over modellen og, der funksjonen kan dreies, en ring for å dreie den.",
|
||||
"ui.placement_handles_tip": "Håndtak på forhåndsvisningen av et anlegg, skruefeste eller snap-fit: dra markøren for å flytte den over modellen, drei den med ringen",
|
||||
"ui.plane": "Plan",
|
||||
"ui.plane_at_angle": "Plan i vinkel",
|
||||
"ui.plugins": "Programtillegg",
|
||||
|
||||
@@ -4929,7 +4929,7 @@
|
||||
"ui.pitch": "Steek",
|
||||
"ui.placement": "Plaatsing",
|
||||
"ui.placement_handles": "Plaatsingsgrepen",
|
||||
"ui.placement_handles_tip": "Een markering om het kenmerk over het model te slepen en, waar het kenmerk kan draaien, een ring om het te draaien.",
|
||||
"ui.placement_handles_tip": "Grepen op het voorbeeld van een Rustvlak, Nok of Snapverbinding: sleep de markering om hem over het model te verplaatsen, draai hem aan de ring",
|
||||
"ui.plane": "Vlak",
|
||||
"ui.plane_at_angle": "Vlak onder hoek",
|
||||
"ui.plugins": "Plug-ins",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "Zaimportowane pliki",
|
||||
"prefs.design_tools.note": "Uwaga",
|
||||
"prefs.design_tools.off": "Wyłączone",
|
||||
"prefs.design_tools.placement_group": "Umieszczane operacje",
|
||||
"prefs.design_tools.placement_group": "Podparcie, Występ, Zatrzask",
|
||||
"prefs.design_tools.rows": "Wiersze",
|
||||
"prefs.devices": "Mysz 3D",
|
||||
"prefs.dialog_buttons": "Przyciski okien dialogowych",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "Skok",
|
||||
"ui.placement": "Umiejscowienie",
|
||||
"ui.placement_handles": "Uchwyty umieszczania",
|
||||
"ui.placement_handles_tip": "Znacznik do przeciągania operacji po modelu i, tam gdzie operację można obracać, pierścień do jej obracania.",
|
||||
"ui.placement_handles_tip": "Uchwyty na podglądzie podparcia, występu lub zatrzasku: przeciągnij znacznik, aby przesunąć element po modelu, obróć go pierścieniem",
|
||||
"ui.plane": "Płaszczyzna",
|
||||
"ui.plane_at_angle": "Płaszczyzna pod kątem",
|
||||
"ui.plugins": "Wtyczki",
|
||||
|
||||
@@ -2588,7 +2588,7 @@
|
||||
"prefs.design_tools.imported_files": "Arquivos importados",
|
||||
"prefs.design_tools.note": "Observação",
|
||||
"prefs.design_tools.off": "Desativado",
|
||||
"prefs.design_tools.placement_group": "Recursos posicionados",
|
||||
"prefs.design_tools.placement_group": "Apoio, Ressalto, Encaixe por pressão",
|
||||
"prefs.design_tools.rows": "Linhas",
|
||||
"prefs.devices": "Mouse 3D",
|
||||
"prefs.dialog_buttons": "Botões das caixas de diálogo",
|
||||
@@ -5927,7 +5927,7 @@
|
||||
"ui.pitch": "Passo",
|
||||
"ui.placement": "Posicionamento",
|
||||
"ui.placement_handles": "Alças de posicionamento",
|
||||
"ui.placement_handles_tip": "Um marcador para arrastar o recurso pelo modelo e, onde o recurso pode girar, um anel para girá-lo.",
|
||||
"ui.placement_handles_tip": "Um marcador para arrastar o recurso pelo modelo e um anel para girá-lo.",
|
||||
"ui.plane": "Plano",
|
||||
"ui.plane_at_angle": "Plano em ângulo",
|
||||
"ui.plugins": "Plug-ins",
|
||||
|
||||
@@ -4915,7 +4915,7 @@
|
||||
"ui.pitch": "Pas",
|
||||
"ui.placement": "Plasare",
|
||||
"ui.placement_handles": "Mânere de plasare",
|
||||
"ui.placement_handles_tip": "Un marcaj pentru a trage operația pe model și, acolo unde operația se poate roti, un inel pentru a o roti.",
|
||||
"ui.placement_handles_tip": "Mânere pe previzualizarea unui Reazem, Bosaj sau a unei Prinderi cu clichet: trageți marcajul pentru a-l muta pe model, rotiți-l cu inelul său",
|
||||
"ui.plane": "Plan",
|
||||
"ui.plane_at_angle": "Plan în unghi",
|
||||
"ui.plugins": "Pluginuri",
|
||||
|
||||
@@ -4870,7 +4870,7 @@
|
||||
"ui.pitch": "Шаг",
|
||||
"ui.placement": "Размещение",
|
||||
"ui.placement_handles": "Манипуляторы размещения",
|
||||
"ui.placement_handles_tip": "Маркер для перетаскивания операции по модели и, где операцию можно поворачивать, кольцо для её поворота.",
|
||||
"ui.placement_handles_tip": "Манипуляторы на предпросмотре «Опоры», «Бобышки» или «Защёлки»: перетащите маркер, чтобы переместить его по модели, поверните за кольцо",
|
||||
"ui.plane": "Плоскость",
|
||||
"ui.plane_at_angle": "Плоскость под углом",
|
||||
"ui.plugins": "Плагины",
|
||||
|
||||
@@ -4907,7 +4907,7 @@
|
||||
"ui.pitch": "Корак",
|
||||
"ui.placement": "Постављање",
|
||||
"ui.placement_handles": "Ручице за постављање",
|
||||
"ui.placement_handles_tip": "Ознака за превлачење операције по моделу и, где се операција може окретати, прстен за њено окретање.",
|
||||
"ui.placement_handles_tip": "Ручице на прегледу ослонца, испуста или шкљоцајућег споја: превуците ознаку да је померите по моделу, окрените је њеним прстеном",
|
||||
"ui.plane": "Раван",
|
||||
"ui.plane_at_angle": "Раван под углом",
|
||||
"ui.plugins": "Проширења",
|
||||
|
||||
@@ -4916,7 +4916,7 @@
|
||||
"ui.pitch": "Stigning",
|
||||
"ui.placement": "Placering",
|
||||
"ui.placement_handles": "Placeringshandtag",
|
||||
"ui.placement_handles_tip": "En markör för att dra funktionen över modellen och, där funktionen kan vridas, en ring för att vrida den.",
|
||||
"ui.placement_handles_tip": "Handtag på förhandsvisningen av ett stöd, nav eller snäppfäste: dra markören för att flytta det över modellen, vrid det med dess ring",
|
||||
"ui.plane": "Plan",
|
||||
"ui.plane_at_angle": "Plan i vinkel",
|
||||
"ui.plugins": "Insticksmoduler",
|
||||
|
||||
@@ -4916,7 +4916,7 @@
|
||||
"ui.pitch": "Adım",
|
||||
"ui.placement": "Yerleşim",
|
||||
"ui.placement_handles": "Yerleşim tutamaçları",
|
||||
"ui.placement_handles_tip": "Özelliği model üzerinde sürüklemek için bir işaretçi ve, özellik döndürülebiliyorsa, döndürmek için bir halka.",
|
||||
"ui.placement_handles_tip": "Bir Dayama, Kabarıklık veya Kilit Geçme önizlemesindeki tutamaçlar: işaretçiyi model üzerinde hareket ettirmek için sürükleyin, halkasıyla döndürün",
|
||||
"ui.plane": "Düzlem",
|
||||
"ui.plane_at_angle": "Açılı düzlem",
|
||||
"ui.plugins": "Eklentiler",
|
||||
|
||||
@@ -2623,7 +2623,7 @@
|
||||
"prefs.design_tools.imported_files": "Імпортовані файли",
|
||||
"prefs.design_tools.note": "Примітка",
|
||||
"prefs.design_tools.off": "Вимк.",
|
||||
"prefs.design_tools.placement_group": "Розміщувані операції",
|
||||
"prefs.design_tools.placement_group": "Упор, Бобишка, Защіпкове з'єднання",
|
||||
"prefs.design_tools.rows": "Рядки",
|
||||
"prefs.devices": "3D-миша",
|
||||
"prefs.dialog_buttons": "Кнопки діалогу",
|
||||
@@ -5989,7 +5989,7 @@
|
||||
"ui.pitch": "Крок",
|
||||
"ui.placement": "Розміщення",
|
||||
"ui.placement_handles": "Ручки розміщення",
|
||||
"ui.placement_handles_tip": "Маркер, щоб перетягнути елемент по моделі, і, де елемент можна повернути, кільце, щоб його повернути.",
|
||||
"ui.placement_handles_tip": "Маркер, щоб перетягнути елемент по моделі, і кільце, щоб його повернути.",
|
||||
"ui.plane": "Площина",
|
||||
"ui.plane_at_angle": "Площина під кутом",
|
||||
"ui.plugins": "Плагіни",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "Tệp đã nhập",
|
||||
"prefs.design_tools.note": "Thông báo",
|
||||
"prefs.design_tools.off": "Tắt",
|
||||
"prefs.design_tools.placement_group": "Các tính năng đặt vị trí",
|
||||
"prefs.design_tools.placement_group": "Gối đỡ, Trụ, Khớp gài",
|
||||
"prefs.design_tools.rows": "Hàng",
|
||||
"prefs.devices": "Chuột 3D",
|
||||
"prefs.dialog_buttons": "Nút hộp thoại",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "Bước xoắn",
|
||||
"ui.placement": "Vị trí đặt",
|
||||
"ui.placement_handles": "Tay nắm định vị",
|
||||
"ui.placement_handles_tip": "Dấu để kéo đặc trưng trên mô hình và, ở nơi đặc trưng có thể xoay, vòng để xoay.",
|
||||
"ui.placement_handles_tip": "Dấu để kéo đặc trưng trên mô hình và vòng để xoay.",
|
||||
"ui.plane": "Mặt phẳng",
|
||||
"ui.plane_at_angle": "Mặt phẳng theo góc",
|
||||
"ui.plugins": "Plugin",
|
||||
|
||||
@@ -2588,7 +2588,7 @@
|
||||
"prefs.design_tools.imported_files": "導入的檔案",
|
||||
"prefs.design_tools.note": "提示",
|
||||
"prefs.design_tools.off": "關閉",
|
||||
"prefs.design_tools.placement_group": "放置的特徵",
|
||||
"prefs.design_tools.placement_group": "支座、凸台、卡扣",
|
||||
"prefs.design_tools.rows": "行數",
|
||||
"prefs.devices": "3D 游標",
|
||||
"prefs.dialog_buttons": "對話框按鈕",
|
||||
@@ -5929,7 +5929,7 @@
|
||||
"ui.pitch": "螺距",
|
||||
"ui.placement": "放置",
|
||||
"ui.placement_handles": "放置手柄",
|
||||
"ui.placement_handles_tip": "用於在模型上拖動特徵的標記,以及在特徵可旋轉時用於旋轉的圓環。",
|
||||
"ui.placement_handles_tip": "用於在模型上拖動特徵的標記和用於旋轉的圓環。",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "角度平面",
|
||||
"ui.plugins": "插件",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "导入的文件",
|
||||
"prefs.design_tools.note": "提示",
|
||||
"prefs.design_tools.off": "关闭",
|
||||
"prefs.design_tools.placement_group": "放置的特征",
|
||||
"prefs.design_tools.placement_group": "支座、凸台、卡扣",
|
||||
"prefs.design_tools.rows": "行数",
|
||||
"prefs.devices": "3D 鼠标",
|
||||
"prefs.dialog_buttons": "对话框按钮",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "螺距",
|
||||
"ui.placement": "放置",
|
||||
"ui.placement_handles": "放置手柄",
|
||||
"ui.placement_handles_tip": "用于在模型上拖动特征的标记,以及在特征可旋转时用于旋转的圆环。",
|
||||
"ui.placement_handles_tip": "用于在模型上拖动特征的标记和用于旋转的圆环。",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "角度平面",
|
||||
"ui.plugins": "插件",
|
||||
|
||||
@@ -20,5 +20,4 @@ from an `App` value the app defines. No app types and no engine.
|
||||
| `auth::tokens_match`, `auth::remove_token_file`, `auth::TokenFile`, `auth::pid_path`, `auth::MIN_TOKEN_LEN` | Constant-time compare and token file housekeeping |
|
||||
| `Effect` | What a request still running does when it finishes: reads only, writes a file, or changes; `when_done(document)` says it |
|
||||
| `server::Hub`, `server::Protocol`, `server::Summary`, `server::Waits`, `server::new_token` | The loopback JSON-lines server behind the token (desktop only); the app says what its requests mean |
|
||||
| `client::Connection`, `client::Request`, `client::CallError`, `client::Waits` | The client (desktop only): the app's token on every request, one bounded round trip, a fresh connection after a lost one, the wait for a request still running (`control.pending`); "not running" told from "lost during the call" |
|
||||
| `ControlRequest`, `ControlResponse`, `DocumentWatch`, `DOCUMENT_WATCH` | A request as the window gets it; the document id and revision subscribed connections wait on |
|
||||
|
||||
@@ -1,616 +0,0 @@
|
||||
//! The control channel's client (docs/control-protocol.md): a connection to an app's loopback
|
||||
//! control port, with the app's token ([`App::client_token`]) on every request, one bounded
|
||||
//! round trip per request, a fresh connection after one is lost, and the wait for a request the
|
||||
//! app says is still running (#1618), through `control.pending`. Its errors tell an app that is
|
||||
//! not running from one that went away during a call ([`CallError`]). What a request means, and
|
||||
//! anything an app adds to its parameters, is the caller's.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Effect;
|
||||
use crate::auth::App;
|
||||
|
||||
/// Longest reply line accepted from the app.
|
||||
pub const MAX_REPLY: u64 = 64 << 20;
|
||||
|
||||
/// How long a reply may take: kernel operations (booleans, fillets) on big parts can take a while.
|
||||
pub const READ_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
/// How long the app may take to answer a request sent [`Request::once`]: a little longer than its
|
||||
/// control server waits for a long request (3600 s), so its "still running" reply arrives first.
|
||||
pub const ONCE_TIMEOUT: Duration = Duration::from_secs(3720);
|
||||
|
||||
/// How long connecting to the app may take.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_millis(800);
|
||||
|
||||
/// How often a long wait is reported ([`Connection::with_progress`]).
|
||||
const PROGRESS_EVERY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How an error starts when a request is still running in the app after the wait: the request
|
||||
/// was not cancelled and its reply waits on the connection; [`Connection::pending`] gets it.
|
||||
pub const STILL_RUNNING: &str = "still running:";
|
||||
|
||||
/// How long to wait for a request the app says is still running.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Waits {
|
||||
/// How long a call goes on waiting, asking `control.pending`, after the app said its request
|
||||
/// is still running (the app says so after 60 s), before it says so itself.
|
||||
pub pending: Duration,
|
||||
/// How long a later call, or [`Connection::pending`], waits for a request still running from
|
||||
/// an earlier call.
|
||||
pub settle: Duration,
|
||||
/// How often `control.pending` is asked while waiting.
|
||||
pub poll: Duration,
|
||||
}
|
||||
|
||||
impl Default for Waits {
|
||||
fn default() -> Self {
|
||||
Waits { pending: Duration::from_secs(240), settle: Duration::from_secs(60), poll: Duration::from_millis(500) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a call has no result. Each holds the message for the caller (an agent reads it).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CallError {
|
||||
/// Nothing listens at the address: the app is not running, or it quit before the call was
|
||||
/// sent. The message says how to start it.
|
||||
NotRunning(String),
|
||||
/// The app went away or did not answer during the call, after running it or not.
|
||||
Lost(String),
|
||||
/// The request is still running in the app after the wait (it starts with [`STILL_RUNNING`]),
|
||||
/// or an earlier one is and this one was not sent.
|
||||
StillRunning(String),
|
||||
/// The app answered with an error (a refused token says which token file it came from).
|
||||
Refused(String),
|
||||
/// The reply was not a control reply.
|
||||
BadReply(String),
|
||||
}
|
||||
|
||||
impl CallError {
|
||||
/// The message.
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
CallError::NotRunning(m) | CallError::Lost(m) | CallError::StillRunning(m) | CallError::Refused(m) | CallError::BadReply(m) => m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CallError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.message())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CallError {}
|
||||
|
||||
impl From<CallError> for String {
|
||||
fn from(e: CallError) -> String {
|
||||
e.message().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// A request: its method and parameters, how messages name it and what it does if it is still
|
||||
/// running when its call returns.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Request {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
/// The request in messages ("`engine.execute` (solid.fillet)").
|
||||
pub what: String,
|
||||
/// What it still does when it finishes after its wait.
|
||||
pub effect: Effect,
|
||||
/// Never sent twice: on a fresh connection, waiting up to [`ONCE_TIMEOUT`], and once it is
|
||||
/// being written any error is final, as the app may have run it. Otherwise a request that
|
||||
/// finds its connection lost is sent once more on a fresh one (the app may have restarted).
|
||||
pub once: bool,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// `method` with `params`, named "`method`" in messages, taken to change the document, sent
|
||||
/// again once on a fresh connection when its connection was lost.
|
||||
pub fn new(method: &str, params: Value) -> Self {
|
||||
Request { method: method.to_string(), params, what: format!("`{method}`"), effect: Effect::Changes, once: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// A control reply as a call's result: its `result`, or its `error`.
|
||||
pub fn outcome(v: &Value) -> Result<Value, String> {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// A request the app said is still running. Its reply will come on this connection, through
|
||||
/// `control.pending`, so the connection is kept for it.
|
||||
struct Outstanding {
|
||||
what: String,
|
||||
/// What it does when it finishes; `None`: not known (a request this client did not send).
|
||||
effect: Option<Effect>,
|
||||
since: Instant,
|
||||
}
|
||||
|
||||
impl Outstanding {
|
||||
fn secs(&self) -> u64 {
|
||||
self.since.elapsed().as_secs()
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of a request still running when its call returned, once a later call waited it
|
||||
/// out: kept for [`Connection::pending`].
|
||||
enum Settled {
|
||||
Finished { what: String, reply: Value },
|
||||
Lost { what: String },
|
||||
}
|
||||
|
||||
/// The end of a wait for an outstanding request.
|
||||
enum Waited {
|
||||
/// It finished: its own reply.
|
||||
Done(Value),
|
||||
/// The app holds nothing (any more) for this connection.
|
||||
Nothing,
|
||||
/// Still running when the wait ran out.
|
||||
Running,
|
||||
}
|
||||
|
||||
/// A connection to the app listening at an address. It connects when a call needs it, so a
|
||||
/// client started before the app reaches it once it runs.
|
||||
pub struct Connection {
|
||||
app: App,
|
||||
/// What the app calls its document in messages ("design").
|
||||
document: &'static str,
|
||||
addr: String,
|
||||
port: u16,
|
||||
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
||||
/// Counts the connections made, so a caller can tell a fresh one from the one it asked.
|
||||
serial: u64,
|
||||
next_id: u64,
|
||||
/// The token given, when one was. Otherwise each request looks the token up again, since
|
||||
/// every launch of the app makes a new one.
|
||||
explicit_token: Option<String>,
|
||||
/// A request still running in the app; its reply waits on `conn`.
|
||||
outstanding: Option<Outstanding>,
|
||||
/// An outstanding request's end, once a later call waited for it (for `pending`).
|
||||
settled: Option<Settled>,
|
||||
waits: Waits,
|
||||
/// Where a long wait is reported.
|
||||
progress: fn(&str),
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
/// A connection to `app` at `addr` (`host:port`), with `token` when given, else the app's
|
||||
/// token found the usual way ([`App::client_token`]); not connected yet.
|
||||
pub fn new(app: App, addr: &str, token: Option<&str>) -> Self {
|
||||
let port = addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(0);
|
||||
Self {
|
||||
app,
|
||||
document: "design",
|
||||
addr: addr.to_string(),
|
||||
port,
|
||||
conn: None,
|
||||
serial: 0,
|
||||
next_id: 1,
|
||||
explicit_token: token.map(str::to_string),
|
||||
outstanding: None,
|
||||
settled: None,
|
||||
waits: Waits::default(),
|
||||
progress: |_| {},
|
||||
}
|
||||
}
|
||||
|
||||
/// What the app calls its document in messages ("design", the default).
|
||||
#[must_use]
|
||||
pub fn with_document(mut self, document: &'static str) -> Self {
|
||||
self.document = document;
|
||||
self
|
||||
}
|
||||
|
||||
/// Report a long wait for a request still running to `progress`, every 30 s.
|
||||
#[must_use]
|
||||
pub fn with_progress(mut self, progress: fn(&str)) -> Self {
|
||||
self.progress = progress;
|
||||
self
|
||||
}
|
||||
|
||||
/// How long to wait for a request still running.
|
||||
pub fn set_waits(&mut self, waits: Waits) {
|
||||
self.waits = waits;
|
||||
}
|
||||
|
||||
/// The address it connects to.
|
||||
pub fn addr(&self) -> &str {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// The port of the address (0 when it names none).
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// The token given, if one was.
|
||||
pub fn explicit_token(&self) -> Option<&str> {
|
||||
self.explicit_token.as_deref()
|
||||
}
|
||||
|
||||
/// Whether a connection is open now.
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.conn.is_some()
|
||||
}
|
||||
|
||||
/// The number of connections made so far: the same number while the same connection stays
|
||||
/// open.
|
||||
pub fn serial(&self) -> u64 {
|
||||
self.serial
|
||||
}
|
||||
|
||||
/// Whether a request of this connection is still running in the app.
|
||||
pub fn has_outstanding(&self) -> bool {
|
||||
self.outstanding.is_some()
|
||||
}
|
||||
|
||||
/// Close the connection. A request still running loses its reply with it.
|
||||
pub fn disconnect(&mut self) {
|
||||
self.conn = None;
|
||||
if let Some(o) = self.outstanding.take() {
|
||||
self.settled = Some(Settled::Lost { what: o.what });
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect afresh. Failing to reach the app at all is `NotConnected`, so [`Connection::explain`]
|
||||
/// can tell it from a connection lost during a call.
|
||||
///
|
||||
/// # Errors
|
||||
/// `NotConnected` when nothing listens at the address (or it does not resolve).
|
||||
pub fn connect(&mut self) -> std::io::Result<()> {
|
||||
self.disconnect();
|
||||
let not_connected = |e: std::io::Error| std::io::Error::new(std::io::ErrorKind::NotConnected, e);
|
||||
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
|
||||
for sa in self.addr.to_socket_addrs().map_err(not_connected)? {
|
||||
match TcpStream::connect_timeout(&sa, CONNECT_TIMEOUT) {
|
||||
Ok(s) => {
|
||||
s.set_nodelay(true).ok();
|
||||
s.set_read_timeout(Some(READ_TIMEOUT)).ok();
|
||||
let read = s.try_clone()?;
|
||||
self.conn = Some((BufReader::new(read), s));
|
||||
self.serial += 1;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
Err(not_connected(last))
|
||||
}
|
||||
|
||||
/// See whether the app is there now; the error says why not. The connection doesn't stay
|
||||
/// open: the app closes one that shows no token within a few seconds, and the first call may
|
||||
/// be long away. That call connects again.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`Connection::connect`].
|
||||
pub fn try_connect(&mut self) -> std::io::Result<()> {
|
||||
self.connect()?;
|
||||
self.disconnect();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The error for a failed exchange: whether the app is not running (and how to start it) or
|
||||
/// went away during the call.
|
||||
pub fn explain(&self, e: &std::io::Error) -> CallError {
|
||||
let (addr, port, name, document) = (&self.addr, self.port, self.app.name, self.document);
|
||||
let start = format!("{} --control {port}", name.to_lowercase());
|
||||
if e.kind() == std::io::ErrorKind::NotConnected {
|
||||
CallError::NotRunning(format!(
|
||||
"no {name} app is listening at {addr} ({e}): it is not running, or it quit. Start it with `{start}` (or ask the user to); the \
|
||||
next call connects to it"
|
||||
))
|
||||
} else {
|
||||
CallError::Lost(format!(
|
||||
"the {name} app at {addr} did not answer ({e}): it may have quit or crashed during this call, after running it or not. Inspect \
|
||||
the {document} before sending a change again; if the app quit, start it with `{start}`"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The request line for `method`: a fresh id and the app's token (looked up again for every
|
||||
/// request: an app restarted mid-session has a new one).
|
||||
pub fn request_line(&mut self, method: &str, params: Value) -> String {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let mut msg = json!({"id": id, "method": method, "params": params});
|
||||
if let Some(t) = self.app.client_token(self.port, self.explicit_token.as_deref()) {
|
||||
msg["token"] = json!(t.token);
|
||||
}
|
||||
msg.to_string()
|
||||
}
|
||||
|
||||
/// One request line out and one reply line back, at most [`MAX_REPLY`] long, connecting
|
||||
/// first when not connected. Any failure closes the connection (a timed-out read leaves a
|
||||
/// late reply on the stream).
|
||||
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||
if self.conn.is_none() {
|
||||
self.connect()?;
|
||||
}
|
||||
let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::other("not connected")) };
|
||||
// One write: a request that loses its newline still reaches the app as a request.
|
||||
let sent = writer.write_all(format!("{line}\n").as_bytes()).and_then(|()| writer.flush());
|
||||
if let Err(e) = sent {
|
||||
self.disconnect();
|
||||
return Err(e);
|
||||
}
|
||||
let mut reply = String::new();
|
||||
let n = match reader.by_ref().take(MAX_REPLY).read_line(&mut reply) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
self.disconnect();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if n == 0 || !reply.ends_with('\n') {
|
||||
self.disconnect();
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed"));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
fn set_read_timeout(&self, t: Duration) {
|
||||
if let Some((_, writer)) = &self.conn {
|
||||
writer.set_read_timeout(Some(t)).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// A reply line as JSON.
|
||||
fn parse(reply: &str) -> Result<Value, CallError> {
|
||||
serde_json::from_str(reply).map_err(|e| CallError::BadReply(format!("bad reply from the app: {e}")))
|
||||
}
|
||||
|
||||
/// A control reply as a call's result; a refused token says which token file it came from
|
||||
/// (and whether it is stale).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`CallError::Refused`] with the app's error.
|
||||
pub fn outcome(&self, v: &Value) -> Result<Value, CallError> {
|
||||
outcome(v).map_err(|error| {
|
||||
CallError::Refused(
|
||||
self.app.client_token(self.port, self.explicit_token.as_deref()).map_or_else(|| error.clone(), |t| t.explain_error(&error)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// `method` with `params`, at once and once: not behind a request still running on this
|
||||
/// connection, no second try. The app's whole reply (`ok`, `result` or `error`, and anything
|
||||
/// it adds), for a request the app answers at once on any connection
|
||||
/// (`control.capabilities`, `control.running`).
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`Connection::explain`]; [`CallError::BadReply`].
|
||||
pub fn ask(&mut self, method: &str, params: Value) -> Result<Value, CallError> {
|
||||
let line = self.request_line(method, params);
|
||||
let reply = self.roundtrip(&line).map_err(|e| self.explain(&e))?;
|
||||
Self::parse(&reply)
|
||||
}
|
||||
|
||||
/// `method` with `params`, waiting at most `timeout` for the reply and never behind a request
|
||||
/// still running: for a request the app answers within a frame, asked again later when the
|
||||
/// app is busy. One try.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`CallError::StillRunning`] when a request of this connection is still running; as
|
||||
/// [`Connection::ask`] and [`Connection::outcome`].
|
||||
pub fn quick(&mut self, method: &str, params: Value, timeout: Duration) -> Result<Value, CallError> {
|
||||
if self.outstanding.is_some() {
|
||||
return Err(CallError::StillRunning("a request is still running in the app".into()));
|
||||
}
|
||||
if self.conn.is_none() {
|
||||
self.connect().map_err(|e| self.explain(&e))?;
|
||||
}
|
||||
let line = self.request_line(method, params);
|
||||
self.set_read_timeout(timeout);
|
||||
let reply = self.roundtrip(&line);
|
||||
self.set_read_timeout(READ_TIMEOUT);
|
||||
let reply = reply.map_err(|e| self.explain(&e))?;
|
||||
let v = Self::parse(&reply)?;
|
||||
self.outcome(&v)
|
||||
}
|
||||
|
||||
/// Ask the app, on this connection only, about the request it still runs for it.
|
||||
fn ask_pending(&mut self) -> Result<Value, CallError> {
|
||||
if self.conn.is_none() {
|
||||
self.disconnect();
|
||||
return Err(CallError::Lost("the connection to the app was lost".into()));
|
||||
}
|
||||
let v = self.ask("control.pending", json!({}))?;
|
||||
outcome(&v).map_err(CallError::Refused).inspect_err(|_| self.disconnect())
|
||||
}
|
||||
|
||||
/// `control.cancel` for the request still running on this connection. Sent at once, without
|
||||
/// waiting for that request first, and never on a fresh connection, which has nothing to
|
||||
/// cancel. Once the app no longer holds it (removed, stopped or finished), later calls don't
|
||||
/// wait for it.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`Connection::ask`]; [`CallError::Refused`].
|
||||
pub fn cancel(&mut self, params: Value) -> Result<Value, CallError> {
|
||||
if self.conn.is_none() {
|
||||
return Ok(json!({"cancelled": null, "note": "nothing to cancel: no request of this client is running in the app"}));
|
||||
}
|
||||
let v = self.ask("control.cancel", params)?;
|
||||
let r = outcome(&v).map_err(CallError::Refused);
|
||||
if r.is_ok() {
|
||||
self.outstanding = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Wait up to `limit` for the outstanding request, asking `control.pending` now and then and
|
||||
/// reporting how long it takes. An error: the connection, and so the reply, was lost.
|
||||
fn wait_outstanding(&mut self, limit: Duration) -> Result<Waited, CallError> {
|
||||
let start = Instant::now();
|
||||
let mut told = start;
|
||||
loop {
|
||||
let Some(o) = &self.outstanding else { return Ok(Waited::Nothing) };
|
||||
let what = o.what.clone();
|
||||
let r = self.ask_pending()?;
|
||||
if r.get("pending").is_none_or(Value::is_null) {
|
||||
self.outstanding = None;
|
||||
return Ok(Waited::Nothing);
|
||||
}
|
||||
if r.get("done").and_then(Value::as_bool) == Some(true) {
|
||||
self.outstanding = None;
|
||||
return Ok(Waited::Done(r.get("reply").cloned().unwrap_or(Value::Null)));
|
||||
}
|
||||
let waited = start.elapsed();
|
||||
if waited >= limit {
|
||||
return Ok(Waited::Running);
|
||||
}
|
||||
if told.elapsed() >= PROGRESS_EVERY {
|
||||
let secs = self.outstanding.as_ref().map_or(0, Outstanding::secs);
|
||||
(self.progress)(&format!("{what} is still running in the app ({secs} s); waiting for it"));
|
||||
told = Instant::now();
|
||||
}
|
||||
std::thread::sleep(self.waits.poll.min(limit.saturating_sub(waited)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The error for the outstanding request still running after its wait.
|
||||
fn still_running(&self, earlier: bool) -> CallError {
|
||||
let Some(o) = &self.outstanding else { return CallError::StillRunning(format!("{STILL_RUNNING} a request is still running in the app")) };
|
||||
let document = self.document;
|
||||
let effect =
|
||||
o.effect.map_or_else(|| format!("it was not cancelled and may still change the {document} when it finishes"), |e| e.when_done(document));
|
||||
let settle = self.waits.settle.as_secs();
|
||||
CallError::StillRunning(if earlier {
|
||||
format!(
|
||||
"{STILL_RUNNING} an earlier request, {}, is still running in the app ({} s; {effect}), so this call was not sent. Call the \
|
||||
`pending` tool to wait for its result, then send this call again",
|
||||
o.what,
|
||||
o.secs()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{STILL_RUNNING} {} is still running in the app after {} s ({effect}); don't send it again. Call the `pending` tool to \
|
||||
wait for its result (up to {settle} s per call); other calls wait for it first",
|
||||
o.what,
|
||||
o.secs()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Before a new request: wait for one still running from an earlier call, keeping its end for
|
||||
/// `pending`. Still running after the wait: the new request is not sent.
|
||||
fn settle_earlier(&mut self) -> Result<(), CallError> {
|
||||
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return Ok(()) };
|
||||
match self.wait_outstanding(self.waits.settle) {
|
||||
Ok(Waited::Done(reply)) => self.settled = Some(Settled::Finished { what, reply }),
|
||||
Ok(Waited::Nothing) => {}
|
||||
Ok(Waited::Running) => return Err(self.still_running(true)),
|
||||
// The connection was lost, and the earlier reply with it (`pending` says so): the new
|
||||
// request goes on a fresh connection.
|
||||
Err(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `control.pending` for the client: the end of the request still running from an earlier
|
||||
/// call, waiting for it up to the settle wait: `{pending: what | null, done?, reply?, lost?,
|
||||
/// running_s?}`. Never sent on a fresh connection, which has nothing pending.
|
||||
pub fn pending(&mut self) -> Value {
|
||||
if let Some(s) = self.settled.take() {
|
||||
return match s {
|
||||
Settled::Finished { what, reply } => json!({"pending": what, "done": true, "reply": reply}),
|
||||
Settled::Lost { what } => json!({"pending": what, "lost": true}),
|
||||
};
|
||||
}
|
||||
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return json!({"pending": null}) };
|
||||
match self.wait_outstanding(self.waits.settle) {
|
||||
Ok(Waited::Done(reply)) => json!({"pending": what, "done": true, "reply": reply}),
|
||||
Ok(Waited::Nothing) => json!({"pending": null}),
|
||||
Ok(Waited::Running) => json!({"pending": what, "done": false, "running_s": self.outstanding.as_ref().map_or(0, Outstanding::secs)}),
|
||||
Err(_) => {
|
||||
self.settled = None;
|
||||
json!({"pending": what, "lost": true})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `request` and wait for its reply: the app's whole reply (`ok`, `result` or `error`,
|
||||
/// and anything it adds). First a request still running from an earlier call is waited for
|
||||
/// (except for `control.running`, which the app answers at once); a request the app says is
|
||||
/// still running is waited for through `control.pending`; a request refused unrun as busy
|
||||
/// behind one this client did not know of is sent once more after that one, when
|
||||
/// `retry_busy`.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`Connection::explain`]: a request whose connection is lost is sent once more on a fresh
|
||||
/// one (unless [`Request::once`]), and when nothing listens any more the app went away, maybe
|
||||
/// while running it ([`CallError::Lost`]); [`CallError::StillRunning`]; [`CallError::BadReply`].
|
||||
pub fn exchange(&mut self, request: Request, retry_busy: bool) -> Result<Value, CallError> {
|
||||
let Request { method, params, what, effect, once } = request;
|
||||
if method != "control.running" {
|
||||
self.settle_earlier()?;
|
||||
}
|
||||
let again = retry_busy.then(|| params.clone());
|
||||
let sent = Instant::now();
|
||||
let line = self.request_line(&method, params);
|
||||
let reply = if once {
|
||||
// A fresh connection (an idle one may have been closed by an app restart; connecting
|
||||
// again sends nothing).
|
||||
self.connect().or_else(|_| self.connect()).and_then(|()| {
|
||||
self.set_read_timeout(ONCE_TIMEOUT);
|
||||
let r = self.roundtrip(&line);
|
||||
self.set_read_timeout(READ_TIMEOUT);
|
||||
r
|
||||
})
|
||||
} else {
|
||||
self.roundtrip(&line).or_else(|lost| {
|
||||
self.roundtrip(&line).map_err(|e| if e.kind() == std::io::ErrorKind::NotConnected && lost.kind() != e.kind() { lost } else { e })
|
||||
})
|
||||
}
|
||||
.map_err(|e| self.explain(&e))?;
|
||||
let v = Self::parse(&reply)?;
|
||||
// Timed out in the app, still running: wait for its result on this connection.
|
||||
if v.get("running").and_then(Value::as_bool) == Some(true) {
|
||||
self.outstanding = Some(Outstanding { what: what.clone(), effect: Some(effect), since: sent });
|
||||
(self.progress)(&format!(
|
||||
"{what} is still running in the app after {} s; waiting up to {} s more for it",
|
||||
sent.elapsed().as_secs(),
|
||||
self.waits.pending.as_secs()
|
||||
));
|
||||
let document = self.document;
|
||||
return match self.wait_outstanding(self.waits.pending)? {
|
||||
Waited::Done(reply) => Ok(reply),
|
||||
Waited::Nothing => Err(CallError::Lost(format!(
|
||||
"{what} timed out in the app, which kept no result for it: inspect the {document} before sending it again"
|
||||
))),
|
||||
Waited::Running => Err(self.still_running(false)),
|
||||
};
|
||||
}
|
||||
// Refused unrun behind a request this client did not know of: wait for that one, then
|
||||
// send this one once more.
|
||||
if v.get("busy").and_then(Value::as_bool) == Some(true)
|
||||
&& let Some(params) = again
|
||||
{
|
||||
self.outstanding = Some(Outstanding { what: "an earlier request".into(), effect: None, since: Instant::now() });
|
||||
self.settle_earlier()?;
|
||||
return self.exchange(Request { method, params, what, effect, once }, false);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// [`Connection::exchange`] (retrying a busy refusal once) and the reply's result.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`Connection::exchange`] and [`Connection::outcome`].
|
||||
pub fn call(&mut self, request: Request) -> Result<Value, CallError> {
|
||||
let v = self.exchange(request, true)?;
|
||||
self.outcome(&v)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "client_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,190 +0,0 @@
|
||||
//! The client against the shared server ([`crate::server::Hub`]) on an ephemeral loopback port.
|
||||
|
||||
use super::*;
|
||||
use crate::ControlRequest;
|
||||
use crate::server::{Hub, Protocol, Stop, Summary, Waits as ServerWaits};
|
||||
use std::ffi::OsString;
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
fn no_dir() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
fn no_env(_: &str) -> Option<OsString> {
|
||||
None
|
||||
}
|
||||
|
||||
fn utc(secs: u64) -> String {
|
||||
secs.to_string()
|
||||
}
|
||||
|
||||
/// An app with no token files and no environment: a client sends only the token it is given.
|
||||
const APP: App = App { name: "Probe", data_dir: no_dir, env: no_env, utc, default_port: 0 };
|
||||
|
||||
/// An app whose requests all may change its document.
|
||||
struct Probe;
|
||||
|
||||
impl Protocol for Probe {
|
||||
fn summary(&self, method: &str, _: &Value) -> Summary {
|
||||
Summary { what: format!("`{method}`"), effect: Effect::Changes, command: None, steps: None, long: false }
|
||||
}
|
||||
fn document(&self) -> &'static str {
|
||||
"design"
|
||||
}
|
||||
fn change_event(&self) -> &'static str {
|
||||
"design.changed"
|
||||
}
|
||||
fn revision_method(&self) -> &'static str {
|
||||
"document.revision"
|
||||
}
|
||||
fn docs(&self) -> &'static str {
|
||||
"docs/control-protocol.md"
|
||||
}
|
||||
fn capabilities(&self) -> Value {
|
||||
json!({})
|
||||
}
|
||||
}
|
||||
|
||||
/// The app on `port` (0: an ephemeral one) behind [`TOKEN`]: its window answers each request after
|
||||
/// `delay` with the request's method and parameters, and the server says a request is still
|
||||
/// running after `wait`.
|
||||
fn serve(port: u16, delay: Duration, wait: Duration) -> Stop {
|
||||
// A port just closed may take a moment to be free again on a loaded machine.
|
||||
let started = Instant::now();
|
||||
let mut bound = TcpListener::bind(("127.0.0.1", port));
|
||||
while bound.is_err() && started.elapsed() < Duration::from_secs(2) {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
bound = TcpListener::bind(("127.0.0.1", port));
|
||||
}
|
||||
let listener = bound.unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
std::thread::sleep(delay);
|
||||
let _ = req.reply.send(json!({"ok": true, "result": {"method": req.method, "params": req.params}}));
|
||||
}
|
||||
});
|
||||
let waits = ServerWaits { short: wait, long: wait, token: Duration::from_secs(5) };
|
||||
let hub = Arc::new(Hub::new(Probe, || {}, 1 << 20).with_waits(waits));
|
||||
hub.listen_stoppable(listener, TOKEN.into(), tx, false).unwrap()
|
||||
}
|
||||
|
||||
fn addr(port: u16) -> String {
|
||||
format!("127.0.0.1:{port}")
|
||||
}
|
||||
|
||||
/// A port nothing listens on.
|
||||
fn free_port() -> u16 {
|
||||
TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_is_one_round_trip_with_the_token() {
|
||||
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
|
||||
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
|
||||
assert!(!c.is_connected());
|
||||
let r = c.call(Request::new("document.inspect", json!({"a": 1}))).unwrap();
|
||||
assert_eq!(r, json!({"method": "document.inspect", "params": {"a": 1}}));
|
||||
assert!(c.is_connected());
|
||||
let serial = c.serial();
|
||||
// the next call goes on the same connection
|
||||
c.call(Request::new("document.inspect", json!({}))).unwrap();
|
||||
assert_eq!(c.serial(), serial);
|
||||
// `try_connect` sees the app there and leaves no connection open
|
||||
let mut fresh = Connection::new(APP, &addr(app.port()), Some(TOKEN));
|
||||
fresh.try_connect().unwrap();
|
||||
assert!(!fresh.is_connected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_token_or_none_is_refused() {
|
||||
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
|
||||
for token in [Some("fedcba9876543210fedcba9876543210"), None] {
|
||||
let mut c = Connection::new(APP, &addr(app.port()), token);
|
||||
match c.call(Request::new("document.inspect", json!({}))) {
|
||||
Err(CallError::Refused(e)) => assert!(e.starts_with("unauthorized"), "{token:?}: {e}"),
|
||||
other => panic!("{token:?}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_app_not_running_says_how_to_start_it() {
|
||||
let port = free_port();
|
||||
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
|
||||
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
|
||||
let CallError::NotRunning(m) = &e else { panic!("{e:?}") };
|
||||
assert!(m.starts_with(&format!("no Probe app is listening at 127.0.0.1:{port}")), "{m}");
|
||||
assert!(m.contains(&format!("`probe --control {port}`")), "{m}");
|
||||
assert_eq!(c.try_connect().unwrap_err().kind(), std::io::ErrorKind::NotConnected);
|
||||
}
|
||||
|
||||
/// An app that goes away during a call is lost, not "not running"; the next call says it is not
|
||||
/// running, and once it runs again the next call reaches it.
|
||||
#[test]
|
||||
fn an_app_gone_during_a_call_is_lost_and_reached_again_once_back() {
|
||||
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
|
||||
let port = app.port();
|
||||
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
|
||||
c.call(Request::new("document.inspect", json!({}))).unwrap();
|
||||
app.stop();
|
||||
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
|
||||
let CallError::Lost(m) = &e else { panic!("{e:?}") };
|
||||
assert!(m.contains("may have quit") && m.contains("Inspect the design") && m.contains(&format!("`probe --control {port}`")), "{m}");
|
||||
assert!(matches!(c.call(Request::new("document.inspect", json!({}))), Err(CallError::NotRunning(_))));
|
||||
let _back = serve(port, Duration::ZERO, Duration::from_secs(10));
|
||||
assert_eq!(c.call(Request::new("ui.view", json!({}))).unwrap()["method"], json!("ui.view"));
|
||||
}
|
||||
|
||||
/// A server that reads a request and closes the connection without a reply: the call is lost
|
||||
/// (after one more try on a fresh connection), and the reply is never taken for "not running".
|
||||
#[test]
|
||||
fn a_connection_closed_without_a_reply_is_lost() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let mut line = String::new();
|
||||
let _ = BufReader::new(stream).read_line(&mut line);
|
||||
}
|
||||
});
|
||||
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
|
||||
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
|
||||
assert!(matches!(e, CallError::Lost(_)), "{e:?}");
|
||||
assert!(!c.is_connected());
|
||||
}
|
||||
|
||||
/// A request the app says is still running is waited for through `control.pending`, on the same
|
||||
/// connection, and its own reply is the call's result.
|
||||
#[test]
|
||||
fn a_request_still_running_is_waited_for_through_pending() {
|
||||
let app = serve(0, Duration::from_millis(400), Duration::from_millis(50));
|
||||
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
|
||||
c.set_waits(Waits { pending: Duration::from_secs(10), settle: Duration::from_secs(10), poll: Duration::from_millis(5) });
|
||||
let r = c.call(Request::new("solid.slow", json!({"n": 2}))).unwrap();
|
||||
assert_eq!(r, json!({"method": "solid.slow", "params": {"n": 2}}));
|
||||
assert!(!c.has_outstanding());
|
||||
assert_eq!(c.pending(), json!({"pending": null}));
|
||||
}
|
||||
|
||||
/// Past the wait, the call says the request is still running, and `pending` later has its end.
|
||||
#[test]
|
||||
fn a_request_still_running_past_the_wait_is_reported_and_its_end_kept() {
|
||||
let app = serve(0, Duration::from_millis(400), Duration::from_millis(20));
|
||||
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
|
||||
c.set_waits(Waits { pending: Duration::from_millis(30), settle: Duration::from_secs(10), poll: Duration::from_millis(5) });
|
||||
let mut slow = Request::new("solid.slow", json!({}));
|
||||
slow.what = "`solid.slow` (now)".into();
|
||||
let e = c.call(slow).unwrap_err();
|
||||
let CallError::StillRunning(m) = &e else { panic!("{e:?}") };
|
||||
assert!(m.starts_with(STILL_RUNNING) && m.contains("`solid.slow` (now)") && m.contains("will still apply"), "{m}");
|
||||
assert!(c.has_outstanding());
|
||||
let end = c.pending();
|
||||
assert_eq!(end["done"], json!(true), "{end}");
|
||||
assert_eq!(end["reply"]["result"]["method"], json!("solid.slow"), "{end}");
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
//! The control channel any app may share (docs/control-protocol.md): the per-launch token and its
|
||||
//! files ([`auth`]), the loopback server that admits a connection by the token and hands each
|
||||
//! request to the window ([`server`]), the client that talks to it ([`client`]), the request the
|
||||
//! window answers ([`ControlRequest`]), the document watch subscribed connections wait on
|
||||
//! ([`DocumentWatch`]), and what a request still running does when it finishes ([`Effect`]). It
|
||||
//! needs no UI toolkit; the window's own requests over egui (`ui.screenshot`, `ui.resize`) are
|
||||
//! `solidcraft-control-egui`.
|
||||
//! request to the window ([`server`]), the request the window answers ([`ControlRequest`]), the
|
||||
//! document watch subscribed connections wait on ([`DocumentWatch`]), and what a request still running
|
||||
//! does when it finishes ([`Effect`]). It needs no UI toolkit; the window's own requests over egui
|
||||
//! (`ui.screenshot`, `ui.resize`) are `solidcraft-control-egui`.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod auth;
|
||||
mod cancel;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod client;
|
||||
mod effect;
|
||||
mod request;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -1197,10 +1197,6 @@ pub enum FeatureKind {
|
||||
tool: Option<FaceAt>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
sketch: Option<SketchId>,
|
||||
/// With `sketch`: its curves joined end to end first, so a closed loop cuts along itself
|
||||
/// only. Off (each curve on its own, an open one extended past its ends) in older designs.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
loops: bool,
|
||||
},
|
||||
/// Scale bodies about a point (uniform, or per axis).
|
||||
Scale {
|
||||
|
||||
+7
-13
@@ -9715,7 +9715,7 @@ fn eval_feature(
|
||||
}
|
||||
apply_traced(doc, st, f, made, *operation, &[])
|
||||
}
|
||||
FeatureKind::SplitFace { faces, body, plane, tool, sketch, loops } => {
|
||||
FeatureKind::SplitFace { faces, body, plane, tool, sketch } => {
|
||||
let i = body_at(st, body, &crate::plain(faces))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let mut picked: Vec<usize> = Vec::new();
|
||||
@@ -9735,18 +9735,12 @@ fn eval_feature(
|
||||
}
|
||||
(None, Some(sid)) => {
|
||||
let ss = st.sketch(sid.0).ok_or_else(|| DocError::Unknown(format!("sketch {sid}")))?;
|
||||
// Each curve's exact segments (lines and arcs split by their own plane and cylinder),
|
||||
// or with `loops` the curves joined end to end: a closed chain is not extended.
|
||||
let curves: Vec<Vec<solidcore_record::Seg2>> = if *loops {
|
||||
let ids: Vec<String> = ss.sketch.curves.iter().filter(|c| !c.construction).map(|c| c.id.clone()).collect();
|
||||
surface::curve_chains(ss, &ids)?
|
||||
} else {
|
||||
(0..ss.sketch.curves.len())
|
||||
.filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction))
|
||||
.map(|ci| ss.sketch.segs(ci))
|
||||
.filter(|segs| !segs.is_empty())
|
||||
.collect()
|
||||
};
|
||||
// Each curve's exact segments (lines and arcs split by their own plane and cylinder).
|
||||
let curves: Vec<Vec<solidcore_record::Seg2>> = (0..ss.sketch.curves.len())
|
||||
.filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction))
|
||||
.map(|ci| ss.sketch.segs(ci))
|
||||
.filter(|segs| !segs.is_empty())
|
||||
.collect();
|
||||
if curves.is_empty() {
|
||||
return Err(DocError::Invalid(format!("sketch `{}` has no curves to split with", ss.name)));
|
||||
}
|
||||
|
||||
@@ -824,7 +824,7 @@ const BLEND_STATIONS: usize = 41;
|
||||
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
|
||||
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
|
||||
/// starts a chain of its own.
|
||||
pub(super) fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
|
||||
fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
|
||||
if ids.len() > MAX_CHAIN_CURVES {
|
||||
return Err(DocError::Invalid(format!("at most {MAX_CHAIN_CURVES} curves")));
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
//! A Split Face by sketch curves may save `loops: true`: the sketch's curves are joined end to end
|
||||
//! before the split, so a closed loop of lines and arcs cuts the face along the loop only. An older
|
||||
//! design has none and splits by each curve on its own, an open one extended past its ends, as
|
||||
//! before. The last release warns about the newer format number and opens the design, splitting
|
||||
//! that feature curve by curve (its sides extended across the face).
|
||||
@@ -191,7 +191,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&SPLIT_FACE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal; each curve on its own, an open one extended past its ends) and loops?: bool (default false: true joins the curves end to end first, so a closed loop cuts along itself only)",
|
||||
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
|
||||
),
|
||||
CommandSpec::new("solid.split_body", "Split Body", crate::checked!("solid.split_body", split_body))
|
||||
.at("SOLID", "MODIFY")
|
||||
@@ -2946,7 +2946,6 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
plane: xy(),
|
||||
tool: Some(solidcraft_doc::FaceAt { body: BodyName(tb), point: Position(point) }),
|
||||
sketch: None,
|
||||
loops: false,
|
||||
}
|
||||
} else if p.get("sketch").is_some_and(|v| !v.is_null()) {
|
||||
let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
|
||||
@@ -2956,7 +2955,6 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
plane: xy(),
|
||||
tool: None,
|
||||
sketch: Some(SketchId(sketch)),
|
||||
loops: bool_(p, "loops").unwrap_or(false),
|
||||
}
|
||||
} else {
|
||||
let plane = plane_param(s, p.get("plane"), cmd)?;
|
||||
@@ -2966,7 +2964,6 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
plane,
|
||||
tool: None,
|
||||
sketch: None,
|
||||
loops: false,
|
||||
}
|
||||
};
|
||||
add_face_feature(&picked, s, p, kind)
|
||||
|
||||
@@ -2603,64 +2603,6 @@ fn an_imported_assembly_with_a_repeated_part_moves_as_one() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #13382: a design saved by a version whose STEP reader numbered the file's bodies otherwise
|
||||
/// opens with each body where it was and with its own shape. Here the older reader left the
|
||||
/// file's first body out, so every stored name and tree index after it is one lower. The names
|
||||
/// went by index: each body took the next one's name, and that name put it in the next part's
|
||||
/// component.
|
||||
#[test]
|
||||
fn an_import_saved_by_an_older_step_reader_opens_with_its_bodies_in_place() {
|
||||
use solidcraft_doc::FeatureKind;
|
||||
use solidcraft_doc::kernel::StepNode;
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-13382-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("parts.step").to_string_lossy().to_string();
|
||||
let mut src = Session::default();
|
||||
// Three parts of different sizes, each in its own component and placed apart.
|
||||
for (k, (name, size)) in [("Small", [10, 10, 10]), ("Long", [40, 6, 6]), ("Tall", [8, 8, 30])].into_iter().enumerate() {
|
||||
let made = run(&mut src, "component.create", json!({"name": name}));
|
||||
run(&mut src, "solid.box", json!({"length": size[0], "width": size[1], "height": size[2], "body_name": name}));
|
||||
run(&mut src, "component.activate", json!({"component": 0}));
|
||||
// The first part is grounded where it is made.
|
||||
if k > 0 {
|
||||
run(&mut src, "occurrence.move", json!({"occurrence": made["occurrence"], "translate": [100 * k, 0, 0], "space": "world"}));
|
||||
}
|
||||
}
|
||||
run(&mut src, "file.export", json!({"path": path}));
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "file.insert_step", json!({"path": path}));
|
||||
let names: Vec<String> = s.world_state().unwrap().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names.len(), 3, "{names:?}");
|
||||
let before: Vec<_> = names.iter().map(|n| placed_bounds(&s, n)).collect();
|
||||
|
||||
// The design as the older reader saved it: no body 0, the others numbered from 0.
|
||||
let mut doc = (*s.doc).clone();
|
||||
let f = doc.features.iter_mut().find(|f| matches!(f.kind, FeatureKind::Import { .. })).unwrap();
|
||||
let first = f.body_names.remove(0);
|
||||
fn renumber(nodes: &mut [StepNode]) {
|
||||
for n in nodes {
|
||||
n.bodies = n.bodies.iter().filter(|b| **b != 0).map(|b| b - 1).collect();
|
||||
renumber(&mut n.children);
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Import { components, skip, .. } = &mut f.kind {
|
||||
renumber(components);
|
||||
assert!(skip.is_empty(), "no part is placed twice: {skip:?}");
|
||||
}
|
||||
|
||||
let opened = Session::opened(doc, None);
|
||||
let now: Vec<String> = opened.world_state().unwrap().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(now.len(), 3, "the body the older reader left out is built too: {now:?}");
|
||||
for (n, b) in names.iter().zip(&before) {
|
||||
if *n == first {
|
||||
continue;
|
||||
}
|
||||
let got = placed_bounds(&opened, n);
|
||||
assert!(shifted(*b, got, [0.0; 3]), "{n} opened at {got:?}, was at {b:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A 20 × 30 × 8 plate made in its own component and stood up by its placement (turned
|
||||
/// `angle` degrees about X), the way an imported part set upright is: in the world it lies
|
||||
/// flat at `x0`..`x0 + 20`, y 0..30, z 0..8. The design is active again afterwards.
|
||||
|
||||
@@ -31,6 +31,7 @@ use crate::schema::insert_mesh::{InsertMeshParams, MeshInserted};
|
||||
use crate::schema::insert_step::{InsertStepParams, StepInserted};
|
||||
use crate::schema::new_design::{NewDesignParams, NewDesignResult};
|
||||
use crate::schema::save::{SaveAsParams, SaveParams, SaveResult};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::schema::save_mesh::{SaveMeshResult, SlicerDelivery};
|
||||
use crate::schema::slicers::{SlicerInfo, SlicersParams, SlicersResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
@@ -101,7 +102,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.contract(&EXPORT_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::file::FileExportResult>)
|
||||
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|glb|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; obj: the colours of the bodies go in a material library (`.mtl`) written next to the mesh file; glb: binary glTF in metres with Y up, each body placed and named, its colours, metallic and roughness as materials; a transparent body or face, or a body a render setup gives an image texture or optics, is refused; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10); quality?: standard|fine|finest (STL, OBJ, 3MF and GLB: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts); lay_on_print_faces?: bool (STL and 3MF only, default false: each body with a print face (body.print_face) is written laid on it, as a slicer export lays it → print_faces: the bodies laid, warnings: each body that has a print face but was written as it lies)"),
|
||||
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|glb|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; obj: the colours of the bodies go in a material library (`.mtl`) written next to the mesh file; glb: binary glTF in metres with Y up, each body placed and named, its colours, metallic and roughness as materials; a transparent body or face, or a body a render setup gives an image texture or optics, is refused; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10); quality?: standard|fine|finest (STL, OBJ, 3MF and GLB: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
|
||||
// One command per format, so each File › Export item can have a key of its own; started
|
||||
// from a key or command search, the app opens the save picker (the mesh dialog for STL and
|
||||
// 3MF). Each takes `file.export`'s parameters for its format, without `format`.
|
||||
@@ -116,13 +117,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.contract(&EXPORT_STL_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::file::ExportResult>)
|
||||
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); lay_on_print_faces?: bool (default false: as file.export) → {path, bytes, format, print_faces?, warnings?}: binary STL, whatever the path's extension"),
|
||||
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard) → {path, bytes, format}: binary STL, whatever the path's extension"),
|
||||
CommandSpec::new("file.export_3mf", "Export 3MF", crate::checked!("file.export_3mf", export_3mf))
|
||||
.icon("mesh_export")
|
||||
.noundo()
|
||||
.contract(&EXPORT_3MF_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::file::ExportResult>)
|
||||
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); objects?: body|component (as file.export); lay_on_print_faces?: bool (default false: as file.export) → {path, bytes, format, print_faces?, warnings?}: 3MF, whatever the path's extension"),
|
||||
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); objects?: body|component (as file.export) → {path, bytes, format}: 3MF, whatever the path's extension"),
|
||||
CommandSpec::new("file.export_obj", "Export OBJ", crate::checked!("file.export_obj", export_obj_cmd))
|
||||
.icon("export")
|
||||
.noundo()
|
||||
@@ -141,7 +142,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.contract(&SAVE_MESH_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::save_mesh::SaveMeshResult>)
|
||||
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id or name from file.slicers, the program path it lists, or a program the user added under Custom… (send the saved file there, through the same steps as file.send_to_slicer: a slicer that has to be started first is waited for; may be deprecated later in favour of file.send_to_slicer or a Send to App workflow, kept for scripts); single_instance?: bool (default true; see file.send_to_slicer); quality?: standard|fine|finest (STL, OBJ and 3MF: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts); lay_on_print_faces?: bool (default false: each body with a print face (body.print_face) is saved laid on it; a file sent to `slicer` is laid on them whatever this says)"),
|
||||
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id or name from file.slicers, the program path it lists, or a program the user added under Custom… (send the saved file there, through the same steps as file.send_to_slicer: a slicer that has to be started first is waited for; may be deprecated later in favour of file.send_to_slicer or a Send to App workflow, kept for scripts); single_instance?: bool (default true; see file.send_to_slicer); quality?: standard|fine|finest (STL, OBJ and 3MF: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
|
||||
CommandSpec::new("file.send_to_slicer", "Send to Slicer", send_to_slicer)
|
||||
.enabled(starts_programs)
|
||||
.at("INSERT", "FILES")
|
||||
@@ -178,7 +179,7 @@ const EXPORT_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportP
|
||||
const SAVE_CONTRACT: TypedContract<SaveParams> = TypedContract::new();
|
||||
const SAVE_AS_CONTRACT: TypedContract<SaveAsParams> = TypedContract::new();
|
||||
const EXPORT_STEP_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStepParams> = crate::schema::TypedContract::new();
|
||||
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStlParams> = crate::schema::TypedContract::new();
|
||||
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
|
||||
const EXPORT_3MF_CONTRACT: crate::schema::TypedContract<crate::schema::file::Export3mfParams> = crate::schema::TypedContract::new();
|
||||
const EXPORT_OBJ_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
|
||||
const SLICERS_CONTRACT: TypedContract<SlicersParams> = TypedContract::new();
|
||||
@@ -1222,7 +1223,8 @@ fn export_to(
|
||||
)?
|
||||
};
|
||||
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let reply = ExportResult { path: path.to_string(), bytes: bytes.len(), format: format!("{format:?}"), ..ExportResult::default() };
|
||||
let reply =
|
||||
ExportResult { path: path.to_string(), bytes: bytes.len(), format: format!("{format:?}"), files: Vec::new(), param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -1231,60 +1233,8 @@ fn export_thumbnail(s: &Session, format: Format, bodies: &[String]) -> Option<Ve
|
||||
matches!(format, Format::ThreeMf | Format::ThreeMfTagged).then(|| thumbnail::exported(s, bodies)).flatten()
|
||||
}
|
||||
|
||||
/// `lay_on_print_faces` as the mesh exports read it: true writes each body with a print face laid
|
||||
/// on it, as a slicer export does. Only STL and 3MF take it; `false` is accepted for any format.
|
||||
fn lay_on_print_faces(p: &Value, cmd: &str, format: Format) -> Result<bool> {
|
||||
match p.get("lay_on_print_faces") {
|
||||
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(false),
|
||||
Some(Value::Bool(true)) if matches!(format, Format::StlBinary | Format::StlAscii | Format::ThreeMf | Format::ThreeMfTagged) => Ok(true),
|
||||
Some(Value::Bool(true)) => Err(bad(cmd, "`lay_on_print_faces` is for STL and 3MF: other formats are not printed")),
|
||||
Some(_) => Err(bad(cmd, "`lay_on_print_faces` must be true or false")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the bodies as a mesh file (STL or 3MF), each laid on its print face: the file's size, and
|
||||
/// which bodies were laid and which were written as they lie.
|
||||
fn write_laid(
|
||||
s: &Session,
|
||||
path: &str,
|
||||
format: Format,
|
||||
bodies: &[String],
|
||||
parts: &[(String, Vec<String>)],
|
||||
quality: solidcraft_io::MeshQuality,
|
||||
) -> Result<(usize, print_face::Laid)> {
|
||||
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
|
||||
let mut state = s.doc.painted(&*s.world_state()?);
|
||||
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
|
||||
let thumbnail = export_thumbnail(s, format, bodies);
|
||||
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality, thumbnail.as_deref())?;
|
||||
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok((bytes.len(), laid))
|
||||
}
|
||||
|
||||
/// [`export_to`] for `file.export`, `file.export_stl` and `file.export_3mf` with
|
||||
/// `lay_on_print_faces`: each body laid on its print face.
|
||||
fn export_laid(
|
||||
s: &Session,
|
||||
path: &str,
|
||||
format: Format,
|
||||
bodies: &[String],
|
||||
parts: &[(String, Vec<String>)],
|
||||
quality: solidcraft_io::MeshQuality,
|
||||
) -> Result<Value> {
|
||||
let (bytes, laid) = write_laid(s, path, format, bodies, parts, quality)?;
|
||||
let reply = ExportResult {
|
||||
path: path.to_string(),
|
||||
bytes,
|
||||
format: format!("{format:?}"),
|
||||
print_faces: laid.bodies,
|
||||
warnings: laid.warnings,
|
||||
..ExportResult::default()
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// [`export_to`] for a mesh file a slicer is sent, or `file.save_mesh` with `lay_on_print_faces`
|
||||
/// (STL or 3MF): each body laid on its print face.
|
||||
/// [`export_to`] for a mesh file a slicer is sent (STL or 3MF): each body laid on its print face.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn export_laid_to(
|
||||
s: &Session,
|
||||
path: &str,
|
||||
@@ -1293,10 +1243,15 @@ fn export_laid_to(
|
||||
parts: &[(String, Vec<String>)],
|
||||
quality: solidcraft_io::MeshQuality,
|
||||
) -> Result<Value> {
|
||||
let (bytes, laid) = write_laid(s, path, format, bodies, parts, quality)?;
|
||||
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
|
||||
let mut state = s.doc.painted(&*s.world_state()?);
|
||||
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
|
||||
let thumbnail = export_thumbnail(s, format, bodies);
|
||||
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality, thumbnail.as_deref())?;
|
||||
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let reply = SaveMeshResult {
|
||||
path: path.to_string(),
|
||||
bytes,
|
||||
bytes: bytes.len(),
|
||||
format: format!("{format:?}"),
|
||||
print_faces: laid.bodies,
|
||||
warnings: laid.warnings,
|
||||
@@ -1318,11 +1273,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let quality = mesh_quality(p, cmd)?;
|
||||
let parts = mesh_parts(s, p, cmd, format)?;
|
||||
let bodies = string_list(cmd, p, "bodies")?;
|
||||
if lay_on_print_faces(p, cmd, format)? {
|
||||
return export_laid(s, path, format, &bodies, &parts, quality);
|
||||
}
|
||||
export_to(s, cmd, path, format, &bodies, &parts, quality)
|
||||
export_to(s, cmd, path, format, &string_list(cmd, p, "bodies")?, &parts, quality)
|
||||
}
|
||||
|
||||
fn export_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1334,22 +1285,14 @@ fn export_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn export_stl(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.export_stl";
|
||||
let path = path_arg(p, cmd)?;
|
||||
let (bodies, quality) = (string_list(cmd, p, "bodies")?, mesh_quality(p, cmd)?);
|
||||
if lay_on_print_faces(p, cmd, Format::StlBinary)? {
|
||||
return export_laid(s, path, Format::StlBinary, &bodies, &[], quality);
|
||||
}
|
||||
export_to(s, cmd, path, Format::StlBinary, &bodies, &[], quality)
|
||||
export_to(s, cmd, path, Format::StlBinary, &string_list(cmd, p, "bodies")?, &[], mesh_quality(p, cmd)?)
|
||||
}
|
||||
|
||||
fn export_3mf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.export_3mf";
|
||||
let path = path_arg(p, cmd)?;
|
||||
let parts = mesh_parts(s, p, cmd, Format::ThreeMf)?;
|
||||
let (bodies, quality) = (string_list(cmd, p, "bodies")?, mesh_quality(p, cmd)?);
|
||||
if lay_on_print_faces(p, cmd, Format::ThreeMf)? {
|
||||
return export_laid(s, path, Format::ThreeMf, &bodies, &parts, quality);
|
||||
}
|
||||
export_to(s, cmd, path, Format::ThreeMf, &bodies, &parts, quality)
|
||||
export_to(s, cmd, path, Format::ThreeMf, &string_list(cmd, p, "bodies")?, &parts, mesh_quality(p, cmd)?)
|
||||
}
|
||||
|
||||
fn export_obj_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1490,9 +1433,6 @@ fn save_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// a possibility, not a plan.
|
||||
let quality = mesh_quality(p, cmd)?;
|
||||
let parts = mesh_parts(s, p, cmd, format)?;
|
||||
// Read before `slicer`, so a wrong value is refused there too; a file sent to a slicer is laid
|
||||
// on its print faces whatever this says.
|
||||
let lay = lay_on_print_faces(p, cmd, format)?;
|
||||
if let Some(spec) = str_(p, "slicer") {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@@ -1516,11 +1456,7 @@ fn save_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
);
|
||||
}
|
||||
let bodies = string_list(cmd, p, "bodies")?;
|
||||
if lay {
|
||||
return export_laid_to(s, path, format, &bodies, &parts, quality);
|
||||
}
|
||||
export_to(s, cmd, path, format, &bodies, &parts, quality)
|
||||
export_to(s, cmd, path, format, &string_list(cmd, p, "bodies")?, &parts, quality)
|
||||
}
|
||||
|
||||
/// The browser build can't start a program.
|
||||
|
||||
@@ -186,107 +186,6 @@ fn node_key(imp: &StepImport, node: &StepNode, depth: usize) -> u64 {
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// For each body `now` gives, the index `stored` gave the same body, or `None` for a body
|
||||
/// `stored` doesn't have. A body is the same one when it has the same place in both trees: the
|
||||
/// same product at the same position among its siblings, and the same position among that
|
||||
/// product's bodies. `None` when the trees disagree about their products (a name or a number of
|
||||
/// children), or a body index is out of range or used twice: then nothing can be matched.
|
||||
fn matched_bodies(stored: &[StepNode], now: &[StepNode], count: usize) -> Option<Vec<Option<usize>>> {
|
||||
let mut matched = vec![None; count];
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let mut pending: Vec<(&[StepNode], &[StepNode])> = vec![(stored, now)];
|
||||
while let Some((was, is)) = pending.pop() {
|
||||
if was.len() != is.len() {
|
||||
return None;
|
||||
}
|
||||
for (a, b) in was.iter().zip(is) {
|
||||
if a.name != b.name {
|
||||
return None;
|
||||
}
|
||||
for (old, new) in a.bodies.iter().zip(&b.bodies) {
|
||||
let slot = matched.get_mut(*new)?;
|
||||
if slot.is_some() || !taken.insert(*old) {
|
||||
return None;
|
||||
}
|
||||
*slot = Some(*old);
|
||||
}
|
||||
pending.push((&a.children, &b.children));
|
||||
}
|
||||
}
|
||||
Some(matched)
|
||||
}
|
||||
|
||||
/// An Import feature written against `imp`'s numbering of the file's bodies (#13382). The
|
||||
/// feature keeps its body names, skipped bodies and linked face names by body index, as the
|
||||
/// STEP reader that inserted the file numbered them. A later reader can number the same file's
|
||||
/// bodies differently, for example when it reads a body an earlier one left out. Each name
|
||||
/// then went to the wrong body, which its name put in another component: bodies opened moved
|
||||
/// and with another body's shape. The stored tree says which body each index was, so each
|
||||
/// stored value moves to the index `imp` gives that body. A body only `imp` has gets the
|
||||
/// reader's name, and is skipped when a new insert would skip it. Returns whether anything
|
||||
/// changed; the feature is left as it is when the trees can't be matched.
|
||||
pub(crate) fn realign_import(f: &mut solidcraft_doc::Feature, imp: &StepImport) -> bool {
|
||||
let FeatureKind::Import { components, skip, .. } = &f.kind else { return false };
|
||||
if components.is_empty() || *components == imp.tree {
|
||||
return false;
|
||||
}
|
||||
let count = imp.bodies.len();
|
||||
let Some(matched) = matched_bodies(components, &imp.tree, count) else { return false };
|
||||
let stored_count = {
|
||||
let mut all = Vec::new();
|
||||
for n in components {
|
||||
collect_bodies(n, &mut all, 0);
|
||||
}
|
||||
all.len()
|
||||
};
|
||||
if stored_count == count && matched.iter().enumerate().all(|(k, m)| *m == Some(k)) {
|
||||
// The same numbering: only placements or names that don't move bodies differ.
|
||||
return false;
|
||||
}
|
||||
let old_skip: std::collections::HashSet<u64> = skip.iter().copied().collect();
|
||||
let new_skip: std::collections::HashSet<u64> = Plan::of(imp).skip.into_iter().collect();
|
||||
let skip: Vec<u64> = (0..count as u64)
|
||||
.filter(|k| match matched.get(*k as usize).copied().flatten() {
|
||||
Some(old) => old_skip.contains(&(old as u64)),
|
||||
None => new_skip.contains(k),
|
||||
})
|
||||
.collect();
|
||||
f.body_names = by_new_index(&matched, &f.body_names);
|
||||
if !f.link_faces.is_empty() {
|
||||
f.link_faces = by_new_index(&matched, &f.link_faces);
|
||||
}
|
||||
if let FeatureKind::Import { components, skip: stored, .. } = &mut f.kind {
|
||||
components.clone_from(&imp.tree);
|
||||
*stored = skip;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `stored`, kept by the old body indices, as a list by the new ones (empty for a new body).
|
||||
fn by_new_index<T: Clone + Default>(matched: &[Option<usize>], stored: &[T]) -> Vec<T> {
|
||||
matched.iter().map(|m| m.and_then(|old| stored.get(old)).cloned().unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
/// [`realign_import`] for every Import feature of `doc` that is not suppressed, read with the
|
||||
/// reader that will build it. The reads are returned so the caller can hold them while it
|
||||
/// evaluates `doc`: the evaluation then finds each file read already instead of reading it again.
|
||||
pub(crate) fn realign_imports(doc: &mut solidcraft_doc::Document) -> Vec<std::sync::Arc<StepImport>> {
|
||||
let mut reads = Vec::new();
|
||||
for f in doc.features.iter_mut().filter(|f| !f.suppressed) {
|
||||
let FeatureKind::Import { step, components, .. } = &f.kind else { continue };
|
||||
if step.is_empty() || components.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// A file that doesn't read fails the feature when it is evaluated, which reports it.
|
||||
let Ok(imp) = solidcraft_doc::kernel::step_import_shared(step) else { continue };
|
||||
if realign_import(f, &imp) {
|
||||
log::info!("{}: the stored body names follow this version's numbering of the file's bodies", f.name);
|
||||
}
|
||||
reads.push(imp);
|
||||
}
|
||||
reads
|
||||
}
|
||||
|
||||
/// The import's component, as the command reports it. The reply types in `schema/insert_mesh.rs`
|
||||
/// and `schema/insert_step.rs` are what the commands answer with these fields.
|
||||
pub(crate) struct Placed {
|
||||
|
||||
@@ -51,7 +51,6 @@ pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_obj
|
||||
pub use features::{auto_operation, extrude_reach, shown_scope};
|
||||
pub(crate) use features::{rail_fit_form, sweep_orientation};
|
||||
pub(crate) use features_more::{MAX_SKETCH_ID, SidePoint, SketchFrameId, axes_mask, side_point, sketch_frame_id};
|
||||
pub(crate) use file::import::realign_imports;
|
||||
pub(crate) use file::mesh_crease_angle;
|
||||
pub use file::open_steps::OpenSteps;
|
||||
pub(crate) use file::{NAMES_MESSAGE, UP_MESSAGE, names_form, up_form, usable_path};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Laying bodies on their print face for a slicer (#8429), or for an STL or 3MF export with
|
||||
//! `lay_on_print_faces`. The bodies being sent are copied (the
|
||||
//! Laying bodies on their print face for a slicer (#8429). The bodies being sent are copied (the
|
||||
//! state the export writes is already a copy), and each copy whose body has a print face is
|
||||
//! turned so the face points down, its centre of mass moved onto the world origin in X and Y,
|
||||
//! and the face moved onto Z = 0: one rigid transform, applied once. The design and its timeline
|
||||
@@ -21,7 +20,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("body.print_face", "Pri
|
||||
.key("Ctrl+B")
|
||||
.contract(&PRINT_FACE_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<PrintFaceResult>)
|
||||
.params("body: its name; point?: [x, y, z] on a flat face of it, in mm (omit to clear) → {body, face?}: marks the face a slicer export (file.send_to_slicer, file.save_mesh with slicer, file.open_in_easyprint), or an STL or 3MF export with lay_on_print_faces (file.export, file.export_stl, file.export_3mf, file.save_mesh), lays the body on: turned so the face points down at Z = 0, the centre of mass over the origin. One per body, replacing the last; flat faces of solid bodies only. A print face that is gone or no longer flat is reported in the export's warnings, and the body exported as it lies")];
|
||||
.params("body: its name; point?: [x, y, z] on a flat face of it, in mm (omit to clear) → {body, face?}: marks the face a slicer export (file.send_to_slicer, file.save_mesh with slicer, file.open_in_easyprint) lays the body on: turned so the face points down at Z = 0, the centre of mass over the origin. One per body, replacing the last; flat faces of solid bodies only. A print face that is gone or no longer flat is reported in the export's warnings, and the body exported as it lies")];
|
||||
|
||||
const PRINT_FACE_CONTRACT: crate::schema::TypedContract<crate::schema::print_face::PrintFaceParams> = crate::schema::TypedContract::new();
|
||||
|
||||
@@ -354,136 +353,6 @@ mod tests {
|
||||
assert_eq!(kept.doc.print_faces, s.doc.print_faces);
|
||||
}
|
||||
|
||||
/// `name` in a fresh folder of the system's temporary folder, for `case`.
|
||||
fn temp_path(case: &str, name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-lay-on-print-faces-{case}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temporary folder");
|
||||
dir.join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// The box around every triangle of the STL file at `path`, in mm.
|
||||
fn stl_box(path: &str) -> ([f64; 3], [f64; 3]) {
|
||||
let triangles = solidcraft_io::read_stl(&std::fs::read(path).expect("the STL file")).expect("an STL file");
|
||||
assert!(!triangles.is_empty(), "{path} has no triangles");
|
||||
let (mut min, mut max) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for p in triangles.iter().flatten() {
|
||||
for k in 0..3 {
|
||||
min[k] = min[k].min(f64::from(p[k]));
|
||||
max[k] = max[k].max(f64::from(p[k]));
|
||||
}
|
||||
}
|
||||
(min, max)
|
||||
}
|
||||
|
||||
/// Near enough for a coordinate an STL file holds as an f32.
|
||||
fn near_f32(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-4
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_stl_export_lays_the_body_on_its_print_face_only_when_asked() {
|
||||
// The 10 x 5 end at X = 20 is the print face, so laid the 20 mm length stands up at Z = 0,
|
||||
// centred on the origin in X and Y.
|
||||
let mut s = block();
|
||||
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
|
||||
let undo = s.undo.len();
|
||||
let laid_box = |(min, max): ([f64; 3], [f64; 3])| {
|
||||
near_f32(min[2], 0.)
|
||||
&& near_f32(max[2], 20.)
|
||||
&& near_f32(min[0] + max[0], 0.)
|
||||
&& near_f32(min[1] + max[1], 0.)
|
||||
&& near_f32((max[0] - min[0]) * (max[1] - min[1]), 50.)
|
||||
};
|
||||
for (id, name, extra) in [
|
||||
("file.export_stl", "a.stl", json!({})),
|
||||
("file.export", "b.stl", json!({})),
|
||||
("file.export", "c.stl", json!({"format": "stla"})),
|
||||
("file.save_mesh", "d.stl", json!({})),
|
||||
("file.save_mesh", "e.stl", json!({"format": "stl", "ascii": true})),
|
||||
] {
|
||||
let path = temp_path("stl", name);
|
||||
let mut p = json!({"path": path, "lay_on_print_faces": true});
|
||||
p.as_object_mut().expect("object").extend(extra.as_object().expect("object").clone());
|
||||
let reply = run(&mut s, id, p);
|
||||
assert_eq!(reply["print_faces"], json!(["Block"]), "{id}: {reply}");
|
||||
assert!(reply.get("warnings").is_none(), "{id}: {reply}");
|
||||
let bbox = stl_box(&path);
|
||||
assert!(laid_box(bbox), "{id} {name}: {bbox:?}");
|
||||
// Without the key, or with it false, the body is written as it lies in the design.
|
||||
for lay in [json!(null), json!(false)] {
|
||||
let path = temp_path("stl-as-it-lies", name);
|
||||
let mut p = json!({"path": path, "lay_on_print_faces": lay});
|
||||
p.as_object_mut().expect("object").extend(extra.as_object().expect("object").clone());
|
||||
let reply = run(&mut s, id, p);
|
||||
assert!(reply.get("print_faces").is_none(), "{id}: {reply}");
|
||||
let (min, max) = stl_box(&path);
|
||||
assert!(near_f32(min[0], 0.) && near_f32(max[0], 20.) && near_f32(min[2], 0.) && near_f32(max[2], 5.), "{id}: {min:?} {max:?}");
|
||||
}
|
||||
}
|
||||
// The design itself has not moved, and nothing was added to undo.
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
let m = solidcraft_kernel::measure_mass(&s.model.state().bodies[0].body).expect("measure");
|
||||
assert!(near(m.bbox.min.x, 0.) && near(m.bbox.max.x, 20.) && near(m.bbox.max.z, 5.), "{:?}", m.bbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_3mf_export_reports_the_bodies_it_laid_on_their_print_face() {
|
||||
let mut s = block();
|
||||
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
|
||||
for (id, name) in [("file.export_3mf", "a.3mf"), ("file.export", "b.3mf"), ("file.save_mesh", "c.3mf")] {
|
||||
let path = temp_path("3mf", name);
|
||||
let reply = run(&mut s, id, json!({"path": path, "lay_on_print_faces": true}));
|
||||
assert_eq!(reply["print_faces"], json!(["Block"]), "{id}: {reply}");
|
||||
assert!(std::fs::metadata(&path).is_ok_and(|m| m.len() > 0), "{id}: {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_with_lay_on_print_faces_writes_a_body_without_one_as_it_lies() {
|
||||
// A second body with no print face lies where it is; a print face cut away is reported.
|
||||
let mut s = block();
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [50, 0, 0], "body_name": "Cube", "operation": "new"}));
|
||||
let path = temp_path("none", "cube.stl");
|
||||
let reply = run(&mut s, "file.export_stl", json!({"path": path, "bodies": ["Cube"], "lay_on_print_faces": true}));
|
||||
assert!(reply.get("print_faces").is_none() && reply.get("warnings").is_none(), "{reply}");
|
||||
let (min, max) = stl_box(&path);
|
||||
assert!(near_f32(min[0], 50.) && near_f32(max[0], 54.) && near_f32(max[2], 4.), "{min:?} {max:?}");
|
||||
run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]}));
|
||||
run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 5, "corner": [-5, -5, 3], "operation": "cut"}));
|
||||
let path = temp_path("cut", "block.stl");
|
||||
let reply = run(&mut s, "file.export_stl", json!({"path": path, "bodies": ["Block"], "lay_on_print_faces": true}));
|
||||
assert!(reply.get("print_faces").is_none(), "{reply}");
|
||||
assert!(reply["warnings"].as_array().is_some_and(|w| w.len() == 1 && w[0].as_str().is_some_and(|w| w.contains("Block"))), "{reply}");
|
||||
let (min, max) = stl_box(&path);
|
||||
assert!(near_f32(min[0], 0.) && near_f32(max[0], 20.) && near_f32(max[2], 3.), "{min:?} {max:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lay_on_print_faces_is_refused_for_a_format_that_is_not_printed_or_a_value_that_is_not_true_or_false() {
|
||||
let mut s = block();
|
||||
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
|
||||
for (id, name, lay) in [
|
||||
("file.export", "a.step", json!(true)),
|
||||
("file.export", "a.obj", json!(true)),
|
||||
("file.export", "a.glb", json!(true)),
|
||||
("file.export", "a.json", json!(true)),
|
||||
("file.export", "b.stl", json!("yes")),
|
||||
("file.export_stl", "c.stl", json!(1)),
|
||||
("file.export_3mf", "d.3mf", json!([true])),
|
||||
("file.save_mesh", "e.stl", json!({})),
|
||||
] {
|
||||
let path = temp_path("refused", name);
|
||||
let e = s.execute(id, &json!({"path": path, "lay_on_print_faces": lay})).expect_err(id).to_string();
|
||||
assert!(e.contains("lay_on_print_faces"), "{id} {name}: {e}");
|
||||
assert!(std::fs::metadata(&path).is_err(), "{id} {name}: a refused export writes nothing");
|
||||
}
|
||||
// False is accepted for any format: the file is written as the design has it.
|
||||
let path = temp_path("false", "a.step");
|
||||
run(&mut s, "file.export", json!({"path": path, "lay_on_print_faces": false}));
|
||||
assert!(std::fs::metadata(&path).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_the_body_keeps_its_print_face() {
|
||||
let mut s = block();
|
||||
|
||||
@@ -494,26 +494,6 @@ fn a_hole_wider_than_the_part_says_it_removes_all_of_it() {
|
||||
assert_eq!(said(&s, "H1"), "");
|
||||
}
|
||||
|
||||
/// A hole across the part's width that touches none of its sides keeps the rest, in one piece and
|
||||
/// silently, and says so once a parameter widens it past the whole part. The same hole touching a
|
||||
/// side along a line is the case above.
|
||||
#[test]
|
||||
fn a_hole_across_the_part_clear_of_its_sides_keeps_the_rest_silently() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "hd", "expression": "40 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "name": "Plate"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [10, 15, 10], "diameter": "hd", "name": "H1"}));
|
||||
assert_eq!(said(&s, "H1"), "");
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
// The plate less the part of the 40 mm circle inside it, 10 mm deep.
|
||||
assert!((volume(&mut s) - 3623.4382).abs() < 1e-3, "{}", volume(&mut s));
|
||||
change(&mut s, "hd", "100 mm");
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
assert!(said(&s, "H1").contains("the hole removes all of Body1"), "{}", said(&s, "H1"));
|
||||
change(&mut s, "hd", "5 mm");
|
||||
assert_eq!(said(&s, "H1"), "");
|
||||
}
|
||||
|
||||
/// The volume of one body.
|
||||
fn body_volume(s: &mut Session, name: &str) -> f64 {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
|
||||
@@ -801,21 +801,14 @@ impl Session {
|
||||
solidcraft_io::vfs::install_checks();
|
||||
// The session keeps what is hidden (an undo step of its own); the design file carries it.
|
||||
let visibility = std::mem::take(&mut doc.visibility);
|
||||
let mut doc = Arc::new(doc);
|
||||
let doc = Arc::new(doc);
|
||||
// A model saved under another check mode holds that mode's results: evaluated again.
|
||||
let model = match Self::saved_model(&doc, saved).filter(|model| model.validation() == validation) {
|
||||
Some(model) => model,
|
||||
None => {
|
||||
// Stored body names follow the numbering of the STEP reader that inserted each
|
||||
// file; this version's reader may number the bodies otherwise (#13382). The
|
||||
// reads are held so the evaluation doesn't read the files again.
|
||||
let _reads = cmd::realign_imports(Arc::make_mut(&mut doc));
|
||||
let mut model = Model::new();
|
||||
model.set_validation(validation);
|
||||
model.evaluate_shared(&doc);
|
||||
model
|
||||
}
|
||||
};
|
||||
let model = Self::saved_model(&doc, saved).filter(|model| model.validation() == validation).unwrap_or_else(|| {
|
||||
let mut model = Model::new();
|
||||
model.set_validation(validation);
|
||||
model.evaluate_shared(&doc);
|
||||
model
|
||||
});
|
||||
Self::with_model(doc, visibility, model)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,8 +52,9 @@ pub struct ExportStepParams {
|
||||
pub bodies: Option<StringList>,
|
||||
}
|
||||
|
||||
/// Export bodies as an OBJ mesh file (`file.export_obj`), the colours of the bodies in a material
|
||||
/// library, `.mtl`, written next to it. The file is OBJ whatever the path's extension.
|
||||
/// Export bodies as a mesh file: binary STL for `file.export_stl`, OBJ for `file.export_obj`
|
||||
/// (the colours of the bodies go in a material library, `.mtl`, written next to it). The file
|
||||
/// has that format whatever the path's extension.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExportMeshParams {
|
||||
@@ -67,26 +68,6 @@ pub struct ExportMeshParams {
|
||||
pub quality: Option<MeshQualityParam>,
|
||||
}
|
||||
|
||||
/// Export bodies as a binary STL file (`file.export_stl`), whatever the path's extension.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExportStlParams {
|
||||
/// The file to write.
|
||||
pub path: String,
|
||||
/// The bodies to export, by name. Default: every body.
|
||||
#[serde(default)]
|
||||
pub bodies: Option<StringList>,
|
||||
/// How closely the mesh follows curved faces. Default: standard.
|
||||
#[serde(default)]
|
||||
pub quality: Option<MeshQualityParam>,
|
||||
/// True writes each body with a print face (`body.print_face`) laid on it, as a slicer export
|
||||
/// lays it: turned so the face points down at Z = 0, its centre of mass over the origin. Each
|
||||
/// body is laid on its own, so several land on the origin together. Default: false, every body
|
||||
/// as it lies in the design.
|
||||
#[serde(default)]
|
||||
pub lay_on_print_faces: Option<bool>,
|
||||
}
|
||||
|
||||
/// Export bodies as a 3MF file, whatever the path's extension.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
@@ -102,11 +83,6 @@ pub struct Export3mfParams {
|
||||
/// What each object in the file holds. Default: body.
|
||||
#[serde(default)]
|
||||
pub objects: Option<ObjectsParam>,
|
||||
/// As `file.export_stl`'s: true writes each body with a print face laid on it. A body that is
|
||||
/// one of the parts of an object (`objects: component`) stays as it lies, so the object keeps
|
||||
/// its shape. Default: false.
|
||||
#[serde(default)]
|
||||
pub lay_on_print_faces: Option<bool>,
|
||||
}
|
||||
|
||||
/// Export the whole assembly as a robot description (URDF), with an STL mesh for each link
|
||||
@@ -164,10 +140,6 @@ pub struct ExportParams {
|
||||
/// URDF only: each joint's velocity limit, a positive number. Default: 10.
|
||||
#[serde(default)]
|
||||
pub velocity: Option<f64>,
|
||||
/// STL and 3MF only: true writes each body with a print face laid on it, as
|
||||
/// `file.export_stl` has it; refused for the other formats. Default: false. Not read for URDF.
|
||||
#[serde(default)]
|
||||
pub lay_on_print_faces: Option<bool>,
|
||||
}
|
||||
|
||||
fn export_required(schema: &mut Schema) {
|
||||
@@ -207,17 +179,8 @@ fn names<E: serde::de::Error>(m: &Map<String, Value>) -> Result<Option<StringLis
|
||||
impl<'de> Deserialize<'de> for ExportParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let m = Map::<String, Value>::deserialize(d)?;
|
||||
let mut out = ExportParams {
|
||||
path: None,
|
||||
format: None,
|
||||
bodies: None,
|
||||
quality: None,
|
||||
objects: None,
|
||||
mesh_prefix: None,
|
||||
effort: None,
|
||||
velocity: None,
|
||||
lay_on_print_faces: None,
|
||||
};
|
||||
let mut out =
|
||||
ExportParams { path: None, format: None, bodies: None, quality: None, objects: None, mesh_prefix: None, effort: None, velocity: None };
|
||||
// The handler reads `path` first and refuses anything but a name it can write to; the
|
||||
// other keys are read only once it has one, and the format is known.
|
||||
out.path = match m.get("path") {
|
||||
@@ -267,19 +230,12 @@ impl<'de> Deserialize<'de> for ExportParams {
|
||||
_ => return Err(refuse(format!("`objects` must be body or component, not {v}"))),
|
||||
},
|
||||
};
|
||||
out.lay_on_print_faces = match m.get("lay_on_print_faces") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Bool(false)) => Some(false),
|
||||
Some(Value::Bool(true)) if matches!(format, Format::StlBinary | Format::StlAscii | Format::ThreeMf | Format::ThreeMfTagged) => Some(true),
|
||||
Some(Value::Bool(true)) => return Err(refuse("`lay_on_print_faces` is for STL and 3MF: other formats are not printed")),
|
||||
Some(_) => return Err(refuse("`lay_on_print_faces` must be true or false")),
|
||||
};
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `file.export_step`, `file.export_stl`, `file.export_3mf` and `file.export_obj` return.
|
||||
#[derive(Debug, Default, Serialize, JsonSchema)]
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExportResult {
|
||||
/// The file written.
|
||||
pub path: String,
|
||||
@@ -291,13 +247,6 @@ pub struct ExportResult {
|
||||
/// OBJ only: every file written, the material library first and the mesh file last.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub files: Vec<String>,
|
||||
/// With `lay_on_print_faces` only: the bodies that were written laid on their print face.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub print_faces: Vec<String>,
|
||||
/// With `lay_on_print_faces` only: a warning for each body that has a print face but was
|
||||
/// written as it lies.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
|
||||
@@ -3,9 +3,9 @@ use std::time::Duration;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
Export3mfParams, ExportMeshParams, ExportParams, ExportStepParams, ExportStlParams, ExportUrdfParams, FileExportResult, NewerFileReport,
|
||||
OpenElsewhere, OpenParams, OpenResult, OpenedDesign, RecoverParams, RecoverResult, RecoveryDiscardParams, RecoveryDiscardResult,
|
||||
RecoveryListParams, RecoveryListResult,
|
||||
Export3mfParams, ExportMeshParams, ExportParams, ExportStepParams, ExportUrdfParams, FileExportResult, NewerFileReport, OpenElsewhere,
|
||||
OpenParams, OpenResult, OpenedDesign, RecoverParams, RecoverResult, RecoveryDiscardParams, RecoveryDiscardResult, RecoveryListParams,
|
||||
RecoveryListResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::recovery::Autosaver;
|
||||
@@ -16,13 +16,9 @@ use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ExportStepParams>(), &["bodies", "path"]);
|
||||
assert_keys(input_schema_for::<ExportMeshParams>(), &["bodies", "path", "quality"]);
|
||||
assert_keys(input_schema_for::<ExportStlParams>(), &["bodies", "lay_on_print_faces", "path", "quality"]);
|
||||
assert_keys(input_schema_for::<Export3mfParams>(), &["bodies", "lay_on_print_faces", "objects", "path", "quality"]);
|
||||
assert_keys(input_schema_for::<Export3mfParams>(), &["bodies", "objects", "path", "quality"]);
|
||||
assert_keys(input_schema_for::<ExportUrdfParams>(), &["effort", "mesh_prefix", "path", "velocity"]);
|
||||
assert_keys(
|
||||
input_schema_for::<ExportParams>(),
|
||||
&["bodies", "effort", "format", "lay_on_print_faces", "mesh_prefix", "objects", "path", "quality", "velocity"],
|
||||
);
|
||||
assert_keys(input_schema_for::<ExportParams>(), &["bodies", "effort", "format", "mesh_prefix", "objects", "path", "quality", "velocity"]);
|
||||
assert_keys(input_schema_for::<OpenParams>(), &["folder", "max_points", "names", "origin", "path", "read_only", "units", "up"]);
|
||||
assert_keys(input_schema_for::<RecoveryListParams>(), &["dir"]);
|
||||
assert_keys(input_schema_for::<RecoverParams>(), &["dir", "discard", "id"]);
|
||||
@@ -248,17 +244,6 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// Mark a print face on the first body of `s`, a box: its side at the lowest X.
|
||||
fn mark_a_print_face(s: &mut Session) {
|
||||
let (name, bbox) = {
|
||||
let world = s.world_state().unwrap_or_else(|e| panic!("{e}"));
|
||||
let body = world.bodies.first().expect("a body");
|
||||
(body.name.clone(), solidcraft_kernel::measure_mass(&body.body).expect("measure").bbox)
|
||||
};
|
||||
let point = [bbox.min.x, (bbox.min.y + bbox.max.y) / 2.0, (bbox.min.z + bbox.max.z) / 2.0];
|
||||
run(s, "body.print_face", json!({"body": name, "point": point}));
|
||||
}
|
||||
|
||||
/// 10 mm cubes in components A (grounded) and B, so a URDF and a 3MF with parts can be written.
|
||||
fn two_cubes() -> Session {
|
||||
let mut s = Session::default();
|
||||
@@ -299,12 +284,6 @@ fn strict_keeps_todays_errors() {
|
||||
("file.export", json!({"path": at("a.urdf"), "effort": 0})),
|
||||
("file.export", json!({"path": at("a.urdf"), "velocity": "fast"})),
|
||||
("file.export", json!({"path": at("a"), "format": "urdf", "velocity": 1e9})),
|
||||
("file.export", json!({"path": at("a.step"), "lay_on_print_faces": true})),
|
||||
("file.export", json!({"path": at("a.obj"), "lay_on_print_faces": true})),
|
||||
("file.export", json!({"path": at("a.stl"), "lay_on_print_faces": "yes"})),
|
||||
("file.export", json!({"path": at("a.3mf"), "lay_on_print_faces": 1})),
|
||||
("file.export_stl", json!({"path": at("a.stl"), "lay_on_print_faces": "yes"})),
|
||||
("file.export_3mf", json!({"path": at("a.3mf"), "lay_on_print_faces": [true]})),
|
||||
("doc.open", json!({"path": ""})),
|
||||
("doc.open", json!({"path": " "})),
|
||||
("doc.open", json!({"path": 5})),
|
||||
@@ -342,19 +321,6 @@ fn the_export_type_reads_each_key_only_for_its_format() {
|
||||
assert!(ok(json!({"path": "a.xyz", "quality": "rough", "effort": -1})).quality.is_none());
|
||||
assert!(ok(json!({"quality": "rough"})).path.is_none());
|
||||
assert!(ok(json!({"path": "a.stl", "bodies": null, "format": null, "quality": null, "objects": null})).bodies.is_none());
|
||||
// `lay_on_print_faces`: true for STL and 3MF only, false for any format, not read for URDF.
|
||||
for path in ["a.stl", "a.3mf"] {
|
||||
assert_eq!(ok(json!({"path": path, "lay_on_print_faces": true})).lay_on_print_faces, Some(true), "{path}");
|
||||
}
|
||||
assert_eq!(ok(json!({"path": "a", "format": "stla", "lay_on_print_faces": true})).lay_on_print_faces, Some(true));
|
||||
assert_eq!(ok(json!({"path": "a.step", "lay_on_print_faces": false})).lay_on_print_faces, Some(false));
|
||||
assert!(ok(json!({"path": "a.urdf", "lay_on_print_faces": "yes"})).lay_on_print_faces.is_none());
|
||||
for path in ["a.step", "a.obj", "a.glb", "a.json"] {
|
||||
let e = read(json!({"path": path, "lay_on_print_faces": true})).expect_err(path);
|
||||
assert!(e.contains("STL and 3MF"), "{path}: {e}");
|
||||
}
|
||||
let e = read(json!({"path": "a.stl", "lay_on_print_faces": "yes"})).expect_err("a string");
|
||||
assert!(e.contains("must be true or false"), "{e}");
|
||||
}
|
||||
|
||||
/// (`conforms` does not read `oneOf`, so `quality` and `objects` values are checked by the type,
|
||||
@@ -368,9 +334,7 @@ fn the_export_schema_takes_what_the_handler_reads() {
|
||||
(json!({"path": "a.3mf", "format": "3mf-tagged", "bodies": ["A", "B"], "quality": "fine", "objects": "component"}), true),
|
||||
(json!({"path": "robot.urdf", "mesh_prefix": "package://robot/meshes/", "effort": 50, "velocity": 2.5}), true),
|
||||
(json!({"path": "a.step", "bodies": "Body1"}), true),
|
||||
(json!({"path": "a.stl", "lay_on_print_faces": true}), true),
|
||||
(json!({}), false),
|
||||
(json!({"path": "a.stl", "lay_on_print_faces": "yes"}), false),
|
||||
(json!({"path": "a.stl", "bodies": [1]}), false),
|
||||
(json!({"path": "a.stl", "colour": true}), false),
|
||||
] {
|
||||
@@ -395,15 +359,10 @@ fn export_replies_match_the_output_schema() {
|
||||
json!({"path": at("f.bin"), "format": "3mf-tagged"}),
|
||||
json!({"path": at("g.json")}),
|
||||
json!({"path": at("h.urdf"), "mesh_prefix": "package://robot/meshes/", "effort": 50, "velocity": 2}),
|
||||
// With a print face marked: `print_faces` in the first, and in the second a warning (the
|
||||
// body is a part of a component's object, so it stays as it lies).
|
||||
json!({"path": at("i.stl"), "lay_on_print_faces": true}),
|
||||
json!({"path": at("j.3mf"), "objects": "component", "lay_on_print_faces": true}),
|
||||
];
|
||||
for level in [ParamCheck::Warn, ParamCheck::Strict] {
|
||||
let mut s = two_cubes();
|
||||
s.param_check = level;
|
||||
mark_a_print_face(&mut s);
|
||||
for p in &calls {
|
||||
let reply = run(&mut s, "file.export", p.clone());
|
||||
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
|
||||
|
||||
@@ -6,11 +6,9 @@ use serde::{Deserialize, Serialize};
|
||||
use super::types::Point3;
|
||||
|
||||
/// Mark the flat face of a body at a point as the body's print face, or clear it. A slicer
|
||||
/// export (`file.send_to_slicer`, `file.save_mesh` with `slicer`, `file.open_in_easyprint`), and an
|
||||
/// STL or 3MF export with `lay_on_print_faces` (`file.export`, `file.export_stl`, `file.export_3mf`,
|
||||
/// `file.save_mesh`), lays the body on its print face: turned so the face points down and lies at
|
||||
/// Z = 0, the centre of mass over the origin. A body has one print face at most; marking another
|
||||
/// replaces it.
|
||||
/// export (`file.send_to_slicer`, `file.save_mesh` with `slicer`, `file.open_in_easyprint`) lays
|
||||
/// the body on its print face: turned so the face points down and lies at Z = 0, the centre of
|
||||
/// mass over the origin. A body has one print face at most; marking another replaces it.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct PrintFaceParams {
|
||||
|
||||
@@ -72,13 +72,6 @@ pub struct SaveMeshKeys {
|
||||
/// other formats. Default: body.
|
||||
#[serde(default)]
|
||||
pub objects: Option<MeshObjects>,
|
||||
/// True saves each body with a print face (`body.print_face`) laid on it, as a slicer export
|
||||
/// lays it: turned so the face points down at Z = 0, its centre of mass over the origin. Each
|
||||
/// body is laid on its own, so several land on the origin together; a part of a 3MF object
|
||||
/// (`objects: component`) stays as it lies. A file sent to `slicer` is laid on them whatever
|
||||
/// this says. Default: false.
|
||||
#[serde(default)]
|
||||
pub lay_on_print_faces: Option<bool>,
|
||||
}
|
||||
|
||||
/// Leaves out the keys the handler never reads for this call: `ascii` unless `format` is `stl`,
|
||||
@@ -352,11 +345,10 @@ pub struct SaveMeshResult {
|
||||
pub bytes: usize,
|
||||
/// The format written: StlBinary, StlAscii, ThreeMf or ThreeMfTagged.
|
||||
pub format: String,
|
||||
/// With `slicer` or `lay_on_print_faces` only: the bodies that were saved laid on their print face.
|
||||
/// With `slicer` only: the bodies that were laid on their print face for the slicer.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub print_faces: Vec<String>,
|
||||
/// With `slicer` or `lay_on_print_faces` only: a warning for each body that has a print face but
|
||||
/// was saved as it lies.
|
||||
/// With `slicer` only: a warning for each body that has a print face but was saved as it lies.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<String>,
|
||||
/// With `slicer` only: what happened when the file was handed over.
|
||||
|
||||
@@ -23,10 +23,7 @@ fn with_box(level: ParamCheck) -> Session {
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<SaveMeshParams>(),
|
||||
&["ascii", "bodies", "format", "lay_on_print_faces", "objects", "path", "quality", "single_instance", "slicer"],
|
||||
);
|
||||
assert_keys(input_schema_for::<SaveMeshParams>(), &["ascii", "bodies", "format", "objects", "path", "quality", "single_instance", "slicer"]);
|
||||
// `path` is the key the handler refuses when it is left out: required in the schema, optional in the type.
|
||||
let schema = input_schema_for::<SaveMeshParams>();
|
||||
assert_eq!(schema.as_value()["required"], json!(["path"]));
|
||||
@@ -77,9 +74,6 @@ fn strict_keeps_todays_errors() {
|
||||
json!({"bodies": {"a": 1}}),
|
||||
json!({"bodies": true}),
|
||||
json!({"format": "stl", "bodies": ["a", null]}),
|
||||
json!({"lay_on_print_faces": "yes"}),
|
||||
json!({"lay_on_print_faces": 1}),
|
||||
json!({"format": "3mf", "lay_on_print_faces": {}}),
|
||||
] {
|
||||
cases.push(("file.save_mesh", p(extra)));
|
||||
}
|
||||
@@ -230,16 +224,6 @@ fn replies_match_the_output_schema() {
|
||||
}
|
||||
assert!(std::fs::read_to_string(at("e.stl")).unwrap().starts_with("solid"));
|
||||
assert!(std::fs::read(at("a.stl")).unwrap().len() > 84);
|
||||
// Laid on their print faces: the bodies laid are named, and the reply still matches.
|
||||
s.execute("body.print_face", &json!({"body": "Block", "point": [10, 5, 5]})).unwrap();
|
||||
for p in [json!({"path": at("r.stl"), "lay_on_print_faces": true}), json!({"path": at("s.3mf"), "lay_on_print_faces": true})] {
|
||||
let r = s.execute("file.save_mesh", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
|
||||
matches(&r);
|
||||
assert_eq!(keys(&r), ["bytes", "format", "path", "print_faces"].map(String::from).into(), "{p}: {r}");
|
||||
assert_eq!(r["print_faces"], json!(["Block"]), "{p}");
|
||||
}
|
||||
s.execute("body.print_face", &json!({"body": "Block"})).unwrap();
|
||||
|
||||
// A misspelt key runs in warn and says so; the reply still matches. Strict refuses it.
|
||||
let p = json!({"path": at("p.stl"), "qualty": "fine"});
|
||||
|
||||
@@ -64,11 +64,6 @@ pub struct SplitFaceParams {
|
||||
/// `tool` is given.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchRef>,
|
||||
/// With `sketch`: true joins its curves end to end first, so a closed loop of lines and arcs
|
||||
/// splits the faces along the loop only. Default: false, each curve on its own, an open one
|
||||
/// extended past its ends.
|
||||
#[serde(default)]
|
||||
pub loops: Option<bool>,
|
||||
/// The plane to split along. Read when neither `tool` nor `sketch` is given; then it is
|
||||
/// required.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -16,7 +16,7 @@ fn schemas_and_the_key_check_agree() {
|
||||
);
|
||||
assert_keys(
|
||||
input_schema_for::<SplitFaceParams>(),
|
||||
&["body", "body_name", "body_names", "faces", "loops", "name", "participants", "plane", "sketch", "tool"],
|
||||
&["body", "body_name", "body_names", "faces", "name", "participants", "plane", "sketch", "tool"],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,8 +155,6 @@ fn the_check_takes_what_the_schema_allows() {
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": {"body": "T", "point": [1, 2, 3]}}), true),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": "Cuts"}), true),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": 7, "name": "Split", "participants": ["*"]}), true),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": "Cuts", "loops": true}), true),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": "Cuts", "loops": null}), true),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": null, "sketch": null, "plane": null, "body": null}), true),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]]}), true),
|
||||
("solid.split_face", &face, json!({"plane": "XY"}), false),
|
||||
@@ -164,7 +162,6 @@ fn the_check_takes_what_the_schema_allows() {
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2]], "plane": "XY"}), false),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "body": 5, "plane": "XY"}), false),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": true}), false),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": "Cuts", "loops": "yes"}), false),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": {"point": [1, 2, 3]}}), false),
|
||||
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "plane": "XY", "line": "cut"}), false),
|
||||
] {
|
||||
|
||||
@@ -55,33 +55,6 @@ fn split_a_face_by_sketch_curves() {
|
||||
assert!((v - 24000.0).abs() < 1e-6 * 24000.0, "{v}");
|
||||
}
|
||||
|
||||
/// A triangle of three lines on the top: by default each side is extended across the face; with
|
||||
/// `loops` the sides are joined first and the closed loop cuts out the triangle only. Open curves
|
||||
/// are extended either way.
|
||||
#[test]
|
||||
fn split_a_face_by_a_closed_loop_of_lines() {
|
||||
let split = |loops: bool| {
|
||||
let mut s = boxed();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Loop"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 5], [30, 5], [20, 25]], "closed": true}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.split_face", json!({"faces": [[20, 12, 20]], "body": "B", "sketch": "Loop", "loops": loops}));
|
||||
faces_volume(&mut s, "B")
|
||||
};
|
||||
let (f, v) = split(true);
|
||||
assert_eq!(f, 7, "the triangle and the rest of the top");
|
||||
assert!((v - 24000.0).abs() < 1e-6 * 24000.0, "{v}");
|
||||
assert!(split(false).0 > 7, "each side extended across the top");
|
||||
|
||||
let mut s = boxed();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cuts"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [10, 20]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[30, 10], [30, 20]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.split_face", json!({"faces": [[20, 15, 20]], "body": "B", "sketch": "Cuts", "loops": true}));
|
||||
assert_eq!(faces_volume(&mut s, "B").0, 8, "two open lines still cut across the top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tool_that_misses_is_refused() {
|
||||
let mut s = boxed();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+423
-54
@@ -1,9 +1,10 @@
|
||||
//! Where MCP tool calls end up: a control-channel method call (`docs/control-protocol.md`).
|
||||
|
||||
use std::time::Duration;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_control::client::{CallError, Connection, Request, outcome};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::control_pending;
|
||||
use solidcraft_engine::panels::ActionSink;
|
||||
@@ -56,35 +57,116 @@ pub trait Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest reply line accepted from the app.
|
||||
const MAX_REPLY: u64 = 64 << 20;
|
||||
|
||||
/// How long a reply may take: kernel operations (booleans, fillets) on big parts can take a while.
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
/// How long the app may take to answer `mcp.options`: the window answers it within a frame, so a
|
||||
/// longer wait means the app is busy and the server asks again on a later call.
|
||||
const OPTIONS_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// How long the app may take to run an `engine.batch`: a little longer than its control server
|
||||
/// waits (3600 s), so its "still running" reply arrives first.
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_secs(3720);
|
||||
|
||||
/// How long a call goes on waiting, asking `control.pending`, after the app said its request is
|
||||
/// still running (the app says so after 60 s; #1618), before it says so itself.
|
||||
const PENDING_WAIT: Duration = Duration::from_secs(240);
|
||||
|
||||
/// How long a later call, or the `pending` tool, waits for a request still running from an
|
||||
/// earlier call.
|
||||
const SETTLE_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How often `control.pending` is asked while waiting.
|
||||
const POLL_EVERY: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How often a long wait is reported on stderr.
|
||||
const PROGRESS_EVERY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How a tool error starts when a request is still running in the app after the wait: the
|
||||
/// request was not cancelled and its reply waits on the connection; the `pending` tool gets it.
|
||||
/// `batch` uses it to tell "still running" from "failed".
|
||||
pub(crate) use solidcraft_control::client::STILL_RUNNING;
|
||||
pub(crate) const STILL_RUNNING: &str = "still running:";
|
||||
|
||||
/// A request the app said is still running (#1618). Its reply will come on this connection,
|
||||
/// through `control.pending`, so the connection is kept for it.
|
||||
struct Outstanding {
|
||||
what: String,
|
||||
/// What it does when it finishes; `None`: not known (a request this bridge did not send).
|
||||
effect: Option<solidcraft_control::Effect>,
|
||||
since: Instant,
|
||||
}
|
||||
|
||||
impl Outstanding {
|
||||
fn secs(&self) -> u64 {
|
||||
self.since.elapsed().as_secs()
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of a request still running when its call returned, once a later call waited it
|
||||
/// out: kept for the `pending` tool.
|
||||
enum Settled {
|
||||
Finished { what: String, reply: Value },
|
||||
Lost { what: String },
|
||||
}
|
||||
|
||||
/// The end of a wait for an outstanding request.
|
||||
enum Waited {
|
||||
/// It finished: its own reply.
|
||||
Done(Value),
|
||||
/// The app holds nothing (any more) for this connection.
|
||||
Nothing,
|
||||
/// Still running when the wait ran out.
|
||||
Running,
|
||||
}
|
||||
|
||||
/// How long to wait for a request still running (tests shorten them).
|
||||
#[cfg(test)]
|
||||
pub(crate) use solidcraft_control::client::Waits;
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Waits {
|
||||
pub pending: Duration,
|
||||
pub settle: Duration,
|
||||
pub poll: Duration,
|
||||
}
|
||||
|
||||
const WAITS: Waits = Waits { pending: PENDING_WAIT, settle: SETTLE_WAIT, poll: POLL_EVERY };
|
||||
|
||||
/// The level a control reply says its request was checked at (#9184), if it says one.
|
||||
fn echoed(v: &Value) -> Option<ParamCheck> {
|
||||
v.get("param_check").and_then(Value::as_str).and_then(ParamCheck::parse)
|
||||
}
|
||||
|
||||
/// A control reply as a call's result.
|
||||
fn outcome(v: &Value) -> Result<Value, String> {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// A running SolidCraft app reached through its loopback control port
|
||||
/// (`solidcraft --control PORT`): the shared control client ([`Connection`]), with the agent's
|
||||
/// folders on every request and the calls checked against their commands' contracts.
|
||||
/// (`solidcraft --control PORT`).
|
||||
pub struct Remote {
|
||||
conn: Connection,
|
||||
addr: String,
|
||||
port: u16,
|
||||
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
||||
next_id: u64,
|
||||
/// `--token`, when given. Otherwise each request looks the token up again, since every
|
||||
/// launch of the app makes a new one (docs/control-protocol.md).
|
||||
explicit_token: Option<String>,
|
||||
/// A request still running in the app; its reply waits on `conn`.
|
||||
outstanding: Option<Outstanding>,
|
||||
/// An outstanding request's end, once a later call waited for it (for `pending`).
|
||||
settled: Option<Settled>,
|
||||
waits: Waits,
|
||||
/// How calls are checked (`--param-check`): by the app, when it checks a request's own
|
||||
/// level, else here before they are sent.
|
||||
param_check: ParamCheck,
|
||||
/// Whether the connected app checks a request's `param_check` itself (#9184), asked once per
|
||||
/// connection (the connection's serial number beside it); `None` until asked.
|
||||
app_checks: Option<(u64, bool)>,
|
||||
/// connection; `None` until asked.
|
||||
app_checks: Option<bool>,
|
||||
/// The level the app said it checked the last reply at (`param_check` beside `ok`).
|
||||
echoed: Option<ParamCheck>,
|
||||
}
|
||||
@@ -93,11 +175,6 @@ pub struct Remote {
|
||||
/// `batch` uses it to tell "never sent" from "sent, reply lost".
|
||||
pub(crate) const NOT_RUNNING: &str = "no SolidCraft app is listening at";
|
||||
|
||||
/// A long wait for a request still running, on stderr.
|
||||
fn note(message: &str) {
|
||||
solidcraft_engine::note!("solidcraft-mcp: {message}");
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// Connect to the app at `addr`, with its control token found the usual way
|
||||
/// (`SOLIDCRAFT_CONTROL_TOKEN`, else the app's token file for that port).
|
||||
@@ -108,15 +185,24 @@ impl Remote {
|
||||
/// [`Remote::connect`] with the token given (`--token`); `None` finds it the usual way.
|
||||
pub fn connect_with_token(addr: &str, token: Option<&str>) -> std::io::Result<Self> {
|
||||
let mut r = Self::new(addr, token);
|
||||
r.conn.connect()?;
|
||||
r.reconnect()?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A bridge to the app at `addr` that connects when a call needs it: an MCP server started
|
||||
/// before the app keeps its tools, and they reach the app once it runs.
|
||||
pub fn new(addr: &str, token: Option<&str>) -> Self {
|
||||
let port = addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(0);
|
||||
let explicit_token = token.map(str::to_string);
|
||||
Self {
|
||||
conn: Connection::new(solidcraft_engine::control_auth::APP, addr, token).with_progress(note),
|
||||
addr: addr.to_string(),
|
||||
port,
|
||||
conn: None,
|
||||
next_id: 1,
|
||||
explicit_token,
|
||||
outstanding: None,
|
||||
settled: None,
|
||||
waits: WAITS,
|
||||
param_check: ParamCheck::Off,
|
||||
app_checks: None,
|
||||
echoed: None,
|
||||
@@ -133,45 +219,312 @@ impl Remote {
|
||||
/// Shorter waits for requests still running (tests).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_waits(&mut self, waits: Waits) {
|
||||
self.conn.set_waits(waits);
|
||||
self.waits = waits;
|
||||
}
|
||||
|
||||
/// Close the connection. A request still running loses its reply with it.
|
||||
fn drop_conn(&mut self) {
|
||||
self.conn = None;
|
||||
// The next connection may reach another app (restarted, another version).
|
||||
self.app_checks = None;
|
||||
if let Some(o) = self.outstanding.take() {
|
||||
self.settled = Some(Settled::Lost { what: o.what });
|
||||
}
|
||||
}
|
||||
|
||||
/// See whether the app is there now (`new` does not); the error says why not. The connection
|
||||
/// doesn't stay open: the app closes one that shows no token within a few seconds, and the
|
||||
/// first call may be long away. That call connects again.
|
||||
pub fn try_connect(&mut self) -> std::io::Result<()> {
|
||||
self.conn.try_connect()
|
||||
self.reconnect()?;
|
||||
self.drop_conn();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `params` with the agent's folders: the app confines the files a request reaches to them
|
||||
/// (control.rs), and to the folders it opened or saved a design in, which count as its open
|
||||
/// design's.
|
||||
fn scoped(params: Value) -> Value {
|
||||
/// Failing to reach the app at all is `NotConnected`, so [`Remote::explain`] can tell it from
|
||||
/// a connection lost during a call.
|
||||
fn reconnect(&mut self) -> std::io::Result<()> {
|
||||
self.drop_conn();
|
||||
let not_connected = |e: std::io::Error| std::io::Error::new(std::io::ErrorKind::NotConnected, e);
|
||||
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
|
||||
for sa in self.addr.to_socket_addrs().map_err(not_connected)? {
|
||||
match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) {
|
||||
Ok(s) => {
|
||||
s.set_nodelay(true).ok();
|
||||
s.set_read_timeout(Some(READ_TIMEOUT)).ok();
|
||||
let read = s.try_clone()?;
|
||||
self.conn = Some((BufReader::new(read), s));
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
Err(not_connected(last))
|
||||
}
|
||||
|
||||
/// The tool error for a failed call: whether the app is not running (and how to start it) or
|
||||
/// went away during the call.
|
||||
fn explain(&self, e: &std::io::Error) -> String {
|
||||
let (addr, port) = (&self.addr, self.port);
|
||||
if e.kind() == std::io::ErrorKind::NotConnected {
|
||||
format!(
|
||||
"{NOT_RUNNING} {addr} ({e}): it is not running, or it quit. Start it with `solidcraft --control {port}` \
|
||||
(or ask the user to); the next call connects to it"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"the SolidCraft app at {addr} did not answer ({e}): it may have quit or crashed during this call, after running it or not. Inspect the design before sending a change again; if the app quit, start it with `solidcraft --control {port}`"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||
if self.conn.is_none() {
|
||||
self.reconnect()?;
|
||||
}
|
||||
let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::other("not connected")) };
|
||||
// One write: a request that loses its newline still reaches the app as a request.
|
||||
let sent = writer.write_all(format!("{line}\n").as_bytes()).and_then(|()| writer.flush());
|
||||
if let Err(e) = sent {
|
||||
self.drop_conn();
|
||||
return Err(e);
|
||||
}
|
||||
let mut reply = String::new();
|
||||
let n = match reader.by_ref().take(MAX_REPLY).read_line(&mut reply) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
// A timed-out read leaves a late reply on the stream: start afresh next time.
|
||||
self.drop_conn();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if n == 0 || !reply.ends_with('\n') {
|
||||
self.drop_conn();
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed"));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
fn set_read_timeout(&self, t: Duration) {
|
||||
if let Some((_, writer)) = &self.conn {
|
||||
writer.set_read_timeout(Some(t)).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// `engine.batch` is never sent twice (#743): it goes on a fresh connection (an idle one may
|
||||
/// have been closed by an app restart; connecting again sends nothing), and once it is being
|
||||
/// written any error is final, as the app may have run it.
|
||||
fn send_batch(&mut self, line: &str) -> std::io::Result<String> {
|
||||
self.reconnect().or_else(|_| self.reconnect())?;
|
||||
self.set_read_timeout(BATCH_TIMEOUT);
|
||||
let r = self.roundtrip(line);
|
||||
self.set_read_timeout(READ_TIMEOUT);
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// The request line for `method`: a fresh id, the agent's folders and the app's token.
|
||||
fn request_line(&mut self, method: &str, params: Value) -> String {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let mut params = params;
|
||||
// The app confines the files this request reaches to the agent's folders (control.rs).
|
||||
if let (Some(scope), Some(o)) = (solidcraft_engine::io::vfs::current_scope(), params.as_object_mut()) {
|
||||
o.insert("allow_paths".into(), json!(scope.roots().iter().map(|r| r.to_string_lossy()).collect::<Vec<_>>()));
|
||||
// And the folders it opened or saved a design in, which count as its open design's.
|
||||
if !scope.design_roots().is_empty() {
|
||||
o.insert("design_folders".into(), json!(scope.design_roots().iter().map(|r| r.to_string_lossy()).collect::<Vec<_>>()));
|
||||
}
|
||||
}
|
||||
params
|
||||
let mut msg = json!({"id": id, "method": method, "params": params});
|
||||
// The token goes with every request (an app restarted mid-session has a new one).
|
||||
let token = solidcraft_engine::control_auth::APP.client_token(self.port, self.explicit_token.as_deref());
|
||||
if let Some(t) = &token {
|
||||
msg["token"] = json!(t.token);
|
||||
}
|
||||
msg.to_string()
|
||||
}
|
||||
|
||||
/// Ask the app, on this connection only, about the request it still runs for it.
|
||||
fn ask_pending(&mut self) -> Result<Value, String> {
|
||||
if self.conn.is_none() {
|
||||
self.drop_conn();
|
||||
return Err("the connection to the app was lost".into());
|
||||
}
|
||||
let line = self.request_line("control.pending", json!({}));
|
||||
let reply = self.roundtrip(&line).map_err(|e| self.explain(&e))?;
|
||||
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
|
||||
outcome(&v).inspect_err(|_| self.drop_conn())
|
||||
}
|
||||
|
||||
/// `control.cancel` for the request still running on this connection (a timed-out call's,
|
||||
/// docs/control-protocol.md). Sent at once, without waiting for that request first, and never
|
||||
/// on a fresh connection, which has nothing to cancel. Once the app no longer holds it
|
||||
/// (removed, stopped or finished), later calls don't wait for it.
|
||||
fn cancel_outstanding(&mut self, params: Value) -> Result<Value, String> {
|
||||
if self.conn.is_none() {
|
||||
return Ok(json!({"cancelled": null, "note": "nothing to cancel: no request of this bridge is running in the app"}));
|
||||
}
|
||||
let line = self.request_line("control.cancel", params);
|
||||
let reply = self.roundtrip(&line).map_err(|e| self.explain(&e))?;
|
||||
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
|
||||
let r = outcome(&v);
|
||||
if r.is_ok() {
|
||||
self.outstanding = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Wait up to `limit` for the outstanding request, asking `control.pending` now and then and
|
||||
/// saying on stderr how long it takes. An error: the connection, and so the reply, was lost.
|
||||
fn wait_outstanding(&mut self, limit: Duration) -> Result<Waited, String> {
|
||||
let start = Instant::now();
|
||||
let mut told = start;
|
||||
loop {
|
||||
let Some(o) = &self.outstanding else { return Ok(Waited::Nothing) };
|
||||
let what = o.what.clone();
|
||||
let r = self.ask_pending()?;
|
||||
if r.get("pending").is_none_or(Value::is_null) {
|
||||
self.outstanding = None;
|
||||
return Ok(Waited::Nothing);
|
||||
}
|
||||
if r.get("done").and_then(Value::as_bool) == Some(true) {
|
||||
self.outstanding = None;
|
||||
return Ok(Waited::Done(r.get("reply").cloned().unwrap_or(Value::Null)));
|
||||
}
|
||||
let waited = start.elapsed();
|
||||
if waited >= limit {
|
||||
return Ok(Waited::Running);
|
||||
}
|
||||
if told.elapsed() >= PROGRESS_EVERY {
|
||||
let secs = self.outstanding.as_ref().map_or(0, Outstanding::secs);
|
||||
solidcraft_engine::note!("solidcraft-mcp: {what} is still running in the app ({secs} s); waiting for it");
|
||||
told = Instant::now();
|
||||
}
|
||||
std::thread::sleep(self.waits.poll.min(limit.saturating_sub(waited)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool error for the outstanding request still running after its wait.
|
||||
fn still_running(&self, earlier: bool) -> String {
|
||||
let Some(o) = &self.outstanding else { return format!("{STILL_RUNNING} a request is still running in the app") };
|
||||
let effect =
|
||||
o.effect.map_or_else(|| "it was not cancelled and may still change the design when it finishes".into(), |e| e.when_done("design"));
|
||||
let settle = self.waits.settle.as_secs();
|
||||
if earlier {
|
||||
format!(
|
||||
"{STILL_RUNNING} an earlier request, {}, is still running in the app ({} s; {effect}), so this call was not sent. Call the \
|
||||
`pending` tool to wait for its result, then send this call again",
|
||||
o.what,
|
||||
o.secs()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{STILL_RUNNING} {} is still running in the app after {} s ({effect}); don't send it again. Call the `pending` tool to \
|
||||
wait for its result (up to {settle} s per call); other calls wait for it first",
|
||||
o.what,
|
||||
o.secs()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Before a new request: wait for one still running from an earlier call, keeping its end for
|
||||
/// `pending`. Still running after the wait: the new request is not sent.
|
||||
fn settle_earlier(&mut self) -> Result<(), String> {
|
||||
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return Ok(()) };
|
||||
match self.wait_outstanding(self.waits.settle) {
|
||||
Ok(Waited::Done(reply)) => self.settled = Some(Settled::Finished { what, reply }),
|
||||
Ok(Waited::Nothing) => {}
|
||||
Ok(Waited::Running) => return Err(self.still_running(true)),
|
||||
// The connection was lost, and the earlier reply with it (`pending` says so): the new
|
||||
// request goes on a fresh connection.
|
||||
Err(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `control.pending` for the bridge: the end of the request still running from an earlier
|
||||
/// call, waiting for it up to the settle wait. Never sent on a fresh connection, which has
|
||||
/// nothing pending.
|
||||
fn pending_status(&mut self) -> Result<Value, String> {
|
||||
if let Some(s) = self.settled.take() {
|
||||
return Ok(match s {
|
||||
Settled::Finished { what, reply } => json!({"pending": what, "done": true, "reply": reply}),
|
||||
Settled::Lost { what } => json!({"pending": what, "lost": true}),
|
||||
});
|
||||
}
|
||||
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return Ok(json!({"pending": null})) };
|
||||
match self.wait_outstanding(self.waits.settle) {
|
||||
Ok(Waited::Done(reply)) => Ok(json!({"pending": what, "done": true, "reply": reply})),
|
||||
Ok(Waited::Nothing) => Ok(json!({"pending": null})),
|
||||
Ok(Waited::Running) => Ok(json!({"pending": what, "done": false, "running_s": self.outstanding.as_ref().map_or(0, Outstanding::secs)})),
|
||||
Err(_) => {
|
||||
self.settled = None;
|
||||
Ok(json!({"pending": what, "lost": true}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `method` sent to the app as the shared client sends a request: named and with its effect
|
||||
/// for messages, `engine.batch` never sent twice (#743), and the level the app checked the
|
||||
/// reply at kept.
|
||||
fn call_once(&mut self, method: &str, params: Value, retry_busy: bool) -> Result<Value, String> {
|
||||
// The app answers `control.running` at once, even behind a request still running: it is
|
||||
// not held back for that one as other calls are.
|
||||
if method != "control.running" {
|
||||
self.settle_earlier()?;
|
||||
}
|
||||
self.echoed = None;
|
||||
let request = Request {
|
||||
method: method.to_string(),
|
||||
what: control_pending::describe(method, ¶ms),
|
||||
effect: control_pending::effect(method, ¶ms),
|
||||
once: method == "engine.batch",
|
||||
params: Self::scoped(params),
|
||||
};
|
||||
let reply = self.conn.exchange(request, retry_busy)?;
|
||||
self.echoed = echoed(&reply);
|
||||
Ok(self.conn.outcome(&reply)?)
|
||||
let what = control_pending::describe(method, ¶ms);
|
||||
let effect = control_pending::effect(method, ¶ms);
|
||||
let again = retry_busy.then(|| params.clone());
|
||||
let sent = Instant::now();
|
||||
let line = self.request_line(method, params);
|
||||
let reply = if method == "engine.batch" {
|
||||
self.send_batch(&line)
|
||||
} else {
|
||||
// One retry on a fresh connection (the app may have restarted). When nothing listens
|
||||
// any more, the app went away, maybe while running this request: say that, not
|
||||
// just "not running".
|
||||
self.roundtrip(&line).or_else(|lost| {
|
||||
self.roundtrip(&line).map_err(|e| if e.kind() == std::io::ErrorKind::NotConnected && lost.kind() != e.kind() { lost } else { e })
|
||||
})
|
||||
}
|
||||
.map_err(|e| self.explain(&e))?;
|
||||
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
|
||||
self.echoed = echoed(&v);
|
||||
// Timed out in the app, still running (#1618): wait for its result on this connection.
|
||||
if v.get("running").and_then(Value::as_bool) == Some(true) {
|
||||
self.outstanding = Some(Outstanding { what: what.clone(), effect: Some(effect), since: sent });
|
||||
solidcraft_engine::note!(
|
||||
"solidcraft-mcp: {what} is still running in the app after {} s; waiting up to {} s more for it",
|
||||
sent.elapsed().as_secs(),
|
||||
self.waits.pending.as_secs()
|
||||
);
|
||||
return match self.wait_outstanding(self.waits.pending)? {
|
||||
Waited::Done(reply) => {
|
||||
self.echoed = echoed(&reply);
|
||||
outcome(&reply)
|
||||
}
|
||||
Waited::Nothing => {
|
||||
Err(format!("{what} timed out in the app, which kept no result for it: inspect the design before sending it again"))
|
||||
}
|
||||
Waited::Running => Err(self.still_running(false)),
|
||||
};
|
||||
}
|
||||
// Refused unrun behind a request this bridge did not know of: wait for that one, then
|
||||
// send this one once more.
|
||||
if v.get("busy").and_then(Value::as_bool) == Some(true)
|
||||
&& let Some(params) = again
|
||||
{
|
||||
self.outstanding = Some(Outstanding { what: "an earlier request".into(), effect: None, since: Instant::now() });
|
||||
self.settle_earlier()?;
|
||||
return self.call_once(method, params, false);
|
||||
}
|
||||
// A refused token: say which token file it came from (and whether it is stale).
|
||||
outcome(&v).map_err(|error| {
|
||||
solidcraft_engine::control_auth::APP
|
||||
.client_token(self.port, self.explicit_token.as_deref())
|
||||
.map_or_else(|| error.clone(), |t| t.explain_error(&error))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,21 +549,21 @@ impl Remote {
|
||||
/// connection with `control.capabilities`, which lists the levels it takes; an older app
|
||||
/// answers "unknown method". Any other answer (no app, a busy one) is asked again next time.
|
||||
fn app_checks(&mut self) -> bool {
|
||||
if let Some((serial, known)) = self.app_checks
|
||||
&& self.conn.is_connected()
|
||||
&& serial == self.conn.serial()
|
||||
{
|
||||
if let Some(known) = self.app_checks {
|
||||
return known;
|
||||
}
|
||||
// Sent ahead of settling a request still running on this connection: the app answers
|
||||
// `control.capabilities` at once, as `control.running`, without taking that one's reply.
|
||||
let reply = self.conn.ask("control.capabilities", Self::scoped(json!({}))).ok();
|
||||
let line = self.request_line("control.capabilities", json!({}));
|
||||
let reply = self.roundtrip(&line).ok().and_then(|r| serde_json::from_str::<Value>(&r).ok());
|
||||
let known = match reply.as_ref().map(outcome) {
|
||||
Some(Ok(c)) => Some(c["param_check"].as_array().is_some_and(|levels| levels.iter().any(|l| l == self.param_check.name()))),
|
||||
Some(Err(e)) if e.starts_with("unknown method") => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
self.app_checks = if self.conn.is_connected() { known.map(|k| (self.conn.serial(), k)) } else { None };
|
||||
if self.conn.is_some() {
|
||||
self.app_checks = known;
|
||||
}
|
||||
known.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -220,7 +573,7 @@ impl Remote {
|
||||
fn confirmed(&mut self, reply: Result<Value, String>) -> Result<Value, String> {
|
||||
let unconfirmed = reply.is_ok() && self.echoed != Some(self.param_check);
|
||||
if unconfirmed {
|
||||
self.app_checks = Some((self.conn.serial(), false));
|
||||
self.app_checks = Some(false);
|
||||
}
|
||||
match reply {
|
||||
Ok(_) if unconfirmed && self.param_check == ParamCheck::Strict => {
|
||||
@@ -305,12 +658,12 @@ impl Remote {
|
||||
impl Backend for Remote {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
match method {
|
||||
"control.pending" => Ok(self.conn.pending()),
|
||||
"control.pending" => self.pending_status(),
|
||||
"engine.execute" if self.param_check != ParamCheck::Off => self.execute_checked(params),
|
||||
"engine.batch" if self.param_check != ParamCheck::Off => self.batch_checked(params),
|
||||
"engine.script" if self.param_check != ParamCheck::Off => self.call_once(method, self.at_level(params), true),
|
||||
// Not behind the request it cancels, as other calls are.
|
||||
"control.cancel" => Ok(self.conn.cancel(Self::scoped(params))?),
|
||||
"control.cancel" => self.cancel_outstanding(params),
|
||||
_ => self.call_once(method, params, true),
|
||||
}
|
||||
}
|
||||
@@ -322,30 +675,46 @@ impl Backend for Remote {
|
||||
}
|
||||
/// The same app with this bridge's token, not yet connected: it connects when asked.
|
||||
fn side_channel(&self) -> Option<Remote> {
|
||||
Some(Remote::new(self.conn.addr(), self.conn.explicit_token()).with_param_check(self.param_check))
|
||||
Some(Remote::new(&self.addr, self.explicit_token.as_deref()).with_param_check(self.param_check))
|
||||
}
|
||||
fn app_options(&mut self) -> Result<Option<Value>, String> {
|
||||
// This runs on the serve loop before a tool call, so it never waits: not behind a request
|
||||
// still running (ask again on a later call), and one try with no retry, because a retry
|
||||
// reconnects with READ_TIMEOUT and could block for minutes.
|
||||
match self.conn.quick("mcp.options", Self::scoped(json!({})), OPTIONS_TIMEOUT) {
|
||||
if self.outstanding.is_some() {
|
||||
return Err("a request is still running in the app".into());
|
||||
}
|
||||
if self.conn.is_none() {
|
||||
self.reconnect().map_err(|e| self.explain(&e))?;
|
||||
}
|
||||
let line = self.request_line("mcp.options", json!({}));
|
||||
self.set_read_timeout(OPTIONS_TIMEOUT);
|
||||
let reply = self.roundtrip(&line);
|
||||
self.set_read_timeout(READ_TIMEOUT);
|
||||
let reply = reply.map_err(|e| self.explain(&e))?;
|
||||
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
|
||||
let r = outcome(&v).map_err(|error| {
|
||||
solidcraft_engine::control_auth::APP
|
||||
.client_token(self.port, self.explicit_token.as_deref())
|
||||
.map_or_else(|| error.clone(), |t| t.explain_error(&error))
|
||||
});
|
||||
match r {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
// An app from before the setting (its agent chat refuses methods it doesn't know):
|
||||
// the server's own options decide.
|
||||
Err(CallError::Refused(e)) if e.starts_with("unknown method") || e.contains("is not available in the agent chat") => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
Err(e) if e.starts_with("unknown method") || e.contains("is not available in the agent chat") => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
if self.conn.is_connected() {
|
||||
format!("connected to the SolidCraft app at {} (the user watches the viewport)", self.conn.addr())
|
||||
if self.conn.is_some() {
|
||||
format!("connected to the SolidCraft app at {} (the user watches the viewport)", self.addr)
|
||||
} else {
|
||||
// Still "connected to the SolidCraft app": the skills tell bridged from headless by it.
|
||||
format!(
|
||||
"connected to the SolidCraft app at {} once it runs (the user watches the viewport). It was not running \
|
||||
when this server started; until it is (`solidcraft --control {}`), tool calls say so",
|
||||
self.conn.addr(),
|
||||
self.conn.port()
|
||||
self.addr, self.port
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,12 +71,14 @@ impl Body {
|
||||
pub(crate) fn fixes() -> UpstreamFixes {
|
||||
let mut f = UpstreamFixes::default();
|
||||
f.fillet_retry_radius_as_first = true;
|
||||
f.fillet_rail_rim_on_face = true;
|
||||
f.fillet_refuse_rails_leaving_faces = true;
|
||||
f.fillet_refuse_face_consuming_blends = true;
|
||||
f.fillet_refuse_non_manifold_result = true;
|
||||
f.fillet_vertex_error_diagnostics = true;
|
||||
f.fillet_corner_ball_patch = true;
|
||||
f.fillet_chamfer_cone_on_arc = true;
|
||||
f.fillet_torus_center_on_axis = true;
|
||||
f.offset_excess_faces_after_merge = true;
|
||||
f.offset_clip_right_side_out = true;
|
||||
f.revolution_offset_axis_both_sides = true;
|
||||
@@ -170,6 +172,7 @@ pub(crate) fn fixes() -> UpstreamFixes {
|
||||
f.local_operation_step_walls = true;
|
||||
f.edge_check_orders_tangent_faces = true;
|
||||
f.thicken_planar_side_walls = true;
|
||||
f.fillet_torus_span_from_start_section = true;
|
||||
f.fillet_knife_edge_rounds = true;
|
||||
f.revolution_round_offset_collapses = true;
|
||||
f.fillet_chamfer_cone_stored_as_cone = true;
|
||||
@@ -184,9 +187,12 @@ pub(crate) fn fixes() -> UpstreamFixes {
|
||||
f.cylinder_plane_tangent_within_tolerance = true;
|
||||
f.boolean_wire_edge_keeps_faceuse_shell = true;
|
||||
f.fillet_nx1_tangent_corner_edge = true;
|
||||
f.fillet_torus_rail_on_axis = true;
|
||||
f.loops_go_on_round_the_vertex = true;
|
||||
f.revolve_splits_curves_on_axis = true;
|
||||
f.cover_wraps_across_closed_seam = true;
|
||||
f.fillet_trace_from_tangent_point_toward_next = true;
|
||||
f.fillet_tangent_point_one_sided_step = true;
|
||||
f.fillet_rails_meet_at_one_end_point = true;
|
||||
f.fillet_trace_reaches_rails = true;
|
||||
f.delete_vertex_joins_seam_iso_curve = true;
|
||||
|
||||
@@ -13080,28 +13080,6 @@ mod settings_search_tests {
|
||||
assert!(!drawn(&h, "Status bar"), "a miss shows nothing");
|
||||
}
|
||||
|
||||
/// The Placed features row is found by the name of every feature that has the marker, not
|
||||
/// only by its own label.
|
||||
#[test]
|
||||
fn preferences_search_finds_placement_handles_by_each_placed_feature() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_TOOLS);
|
||||
h.frames(2);
|
||||
let drawn = |h: &crate::scenario::Harness, label: &str| {
|
||||
let body = crate::scenario::rect_of("dialog:page_body");
|
||||
h.widget(label).is_some_and(|[x, y]| body.is_none_or(|body| body.contains(egui::pos2(x as f32, y as f32))))
|
||||
};
|
||||
for query in ["Placed features", "Rest", "Boss", "Snap Fit", "Box", "Cylinder", "Cone", "Sphere", "Torus", "Coil"] {
|
||||
h.app.prefs_window.shell.filter = query.into();
|
||||
h.frames(4);
|
||||
assert!(drawn(&h, "Placement handles"), "searching '{query}' should show Placement handles");
|
||||
}
|
||||
h.app.prefs_window.shell.filter = "zzzz-no-setting".into();
|
||||
h.frames(4);
|
||||
assert!(!drawn(&h, "Placement handles"), "a miss shows nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_and_tools_short_labels_keep_legacy_search_and_reveal_the_real_control() {
|
||||
for language in ["en", "ja"] {
|
||||
|
||||
@@ -7,7 +7,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::settings_heading(ui, app.tr("prefs.structure.tools"), app.tr("prefs.structure.tools_note"));
|
||||
ui_kit::settings_section(ui, "create", app.tr("ui.create"), |ui| {
|
||||
create_card(ui, |ui| {
|
||||
create_row(ui, app.tr("ui.hole"), None, &["hole"], true, |ui| {
|
||||
create_row(ui, app.tr("ui.hole"), &["hole"], true, |ui| {
|
||||
for (setting, help, key, legacy) in [
|
||||
(
|
||||
setting::hole_snaps as fn(&mut SolveApp, &mut egui::Ui) -> egui::Response,
|
||||
@@ -29,7 +29,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
});
|
||||
create_row(ui, app.tr_label("Extrude"), None, &["extrude"], false, |ui| {
|
||||
create_row(ui, app.tr_label("Extrude"), &["extrude"], false, |ui| {
|
||||
for (setting, help, key) in [
|
||||
(
|
||||
setting::extrude_auto_to_object as fn(&mut SolveApp, &mut egui::Ui) -> egui::Response,
|
||||
@@ -56,9 +56,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::scenario::publish_target("prefs:extrude_view_return", response.rect);
|
||||
});
|
||||
});
|
||||
// Found by the name of every feature that has the marker, as well as by the label.
|
||||
let placed = PLACED_FEATURES.iter().map(|key| app.tr(key)).collect::<Vec<_>>().join(" ");
|
||||
create_row(ui, app.tr("prefs.design_tools.placement_group"), Some(&placed), &["rest", "boss", "snap_fit"], false, |ui| {
|
||||
create_row(ui, app.tr("prefs.design_tools.placement_group"), &["rest", "boss", "snap_fit"], false, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let response = setting::placement_handles(app, ui).backing_on_hover_text(app.tr("ui.placement_handles_tip"));
|
||||
crate::scenario::publish_target("prefs:placement_handles", response.rect);
|
||||
@@ -217,14 +215,8 @@ fn create_card(ui: &mut egui::Ui, rows: impl FnOnce(&mut egui::Ui)) {
|
||||
|
||||
/// One tool in the Create card: its icons in tiles and its name on the left, its settings on the
|
||||
/// right, a rule above every row but the first. Search uses the same block gate as `tool_group`.
|
||||
/// The features with a placement marker, by their name keys: Preferences search finds the
|
||||
/// Placed features row by any of them.
|
||||
const PLACED_FEATURES: [&str; 9] =
|
||||
["cmd.plastic_rest", "cmd.plastic_boss", "cmd.plastic_snap_fit", "ui.box", "ui.cylinder", "ui.cone", "ui.sphere", "ui.torus", "ui.coil"];
|
||||
|
||||
/// A tool's row; `hint` is more text the search finds the row by (it is not shown).
|
||||
fn create_row(ui: &mut egui::Ui, label: &str, hint: Option<&str>, icons: &[&str], first: bool, add: impl FnOnce(&mut egui::Ui)) {
|
||||
let gate = crate::ui_kit::settings_search::block_gate(ui, label, hint);
|
||||
fn create_row(ui: &mut egui::Ui, label: &str, icons: &[&str], first: bool, add: impl FnOnce(&mut egui::Ui)) {
|
||||
let gate = crate::ui_kit::settings_search::block_gate(ui, label, None);
|
||||
let before = crate::ui_kit::settings_search::hits(ui);
|
||||
if gate.as_ref().is_some_and(|gate| !gate.show) {
|
||||
crate::ui_kit::settings_search::run_hidden(ui, label, add);
|
||||
|
||||
@@ -93,9 +93,6 @@ copy of the body so the print face points down at Z = 0, with its centre of mass
|
||||
origin; the design itself does not move. The face is tinted and outlined in the viewport, and a
|
||||
copy made with Move/Copy or Mirror keeps it. A print face that is gone or no longer flat is
|
||||
reported, and the body is sent as it lies. Curved faces and mesh bodies can't be print faces.
|
||||
Scripts and agents can write the same laid-down copies to an STL or 3MF file without a slicer:
|
||||
`file.export_stl`, `file.export_3mf`, `file.export` and `file.save_mesh` take
|
||||
`lay_on_print_faces: true`, and the reply names the bodies that were laid on their print face.
|
||||
Known limitation: after a shell of a body that is already shelled, no face of that body can be
|
||||
marked, as no sketch can be attached to one either; this needs kernel work in solidcore.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Each group is a 120x64 figure at the left of its checkboxes, with a group name u
|
||||
| Extrude: Choose the operation automatically | Checkbox | `Prefs.auto_operation` | `true` | "Join, New Body or Cut, from what the material touches." | `tool-extrude` | |
|
||||
| Extrude: Turn the view to isometric when Extrude opens | Checkbox | `Prefs.extrude_view_isometric` | `false` | none | `tool-extrude` | Retained newer control, directly after automatic operation in the same group. |
|
||||
| Extrude: Go back to the earlier view when Extrude closes | Checkbox | `Prefs.extrude_view_return` | `false` | none | `tool-extrude` | Directly after isometric; disabled unless isometric is on. |
|
||||
| Placed features: Placement handles | Checkbox | `Prefs.placement_handles` | `false` | "A marker to drag the feature across the model and, where the feature can turn, a ring to turn it." | `tool-placement` | |
|
||||
| Rest, Boss, Snap Fit: Placement handles | Checkbox | `Prefs.placement_handles` | `false` | "A marker to drag the feature across the model and a ring to turn it." | `tool-placement` | |
|
||||
|
||||
## Assembly Move
|
||||
|
||||
|
||||
+2
-3
@@ -347,9 +347,8 @@ that every file came back signed. Without the secret (forks) the jobs build unsi
|
||||
- **Test-sign without publishing:** `gh workflow run release.yml -R tangency-labs/solidcraft --ref
|
||||
<branch> -f draft=false`, then check the Windows jobs' summaries and the `release-assets`
|
||||
artifact.
|
||||
- SignPath only signs requests whose jobs leading up to them all ran on GitHub-hosted runners, so
|
||||
the `version` and `windows` jobs stay on GitHub's runners while every other release job runs on
|
||||
Blacksmith.
|
||||
- SignPath only signs runs whose jobs all ran on GitHub-hosted runners, so keep the `version`,
|
||||
`icons` and `windows` jobs off self-hosted runners.
|
||||
|
||||
## Homebrew tap
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
{"case":"adjacent-two-bodies-small","deferred":null,"error":null,"ints":{"api_status_result_vertex_edge_face_counts":[0,1,4,4,1],"body0_topology":{"blocks":[[128403,5122478]],"n":91},"body0_vertex_edge_face_counts":[6,7,2],"body1_topology":{"blocks":[[128403,5122478]],"n":91},"body1_vertex_edge_face_counts":[6,7,2],"result_topology":{"blocks":[[71196,1456742]],"n":49}},"topology":null,"values":{"body0_geometry":{"blocks":[[437.16011999999995,437.16011999999995,41736.076799999995]],"n":186},"body1_geometry":{"blocks":[[654.3001199999998,654.3001199999998,60852.5788]],"n":186},"fillet_point_step":[0.00001],"result_geometry":{"blocks":[[370.2274884099221,373.42748840992215,19893.411576968836]],"n":106}}}
|
||||
{"case":"attributed-box-all-edges-r1","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,24,48,26]},"topology":null,"values":{"attributes":{"blocks":[[120977.06194288167,120977.06194288167,7270559.503909666]],"n":174},"fillet_point_step":[0.00001]}}
|
||||
{"case":"attributed-box-cylinder-arc-round-seam-mid-r2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,15,23,10]},"topology":null,"values":{"attributes":{"blocks":[[501488.75762421096,501488.75762421096,44364677.586285785]],"n":164},"fillet_point_step":[0.00001]}}
|
||||
{"case":"attributed-box-cylinder-arc-round-seam-opposite-r2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,12,18,9]},"topology":null,"values":{"attributes":{"blocks":[[459392.6159228618,459392.6159228618,34982982.892283924]],"n":144},"fillet_point_step":[0.00001]}}
|
||||
{"case":"attributed-box-cylinder-arc-round-seam-opposite-r2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,10,15,8]},"topology":null,"values":{"attributes":{"blocks":[[480413.1912678502,480413.1912678502,34610352.53320036]],"n":140},"fillet_point_step":[0.00001]}}
|
||||
{"case":"attributed-box-cylinder-arc-round-seam-quarter-r2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,12,18,9]},"topology":null,"values":{"attributes":{"blocks":[[459392.61597430886,459392.61597430886,34980837.82187664]],"n":144},"fillet_point_step":[0.00001]}}
|
||||
{"case":"attributed-box-cylinder-arc-round-seam-three-quarter-r2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,12,18,9]},"topology":null,"values":{"attributes":{"blocks":[[459392.61603265547,459392.61603265547,34979788.966652505]],"n":144},"fillet_point_step":[0.00001]}}
|
||||
{"case":"attributed-box-cylinder-arc-round-seam-three-quarter-r2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,10,15,8]},"topology":null,"values":{"attributes":{"blocks":[[480413.19126785017,480413.19126785017,34607083.53077263]],"n":140},"fillet_point_step":[0.00001]}}
|
||||
{"case":"attributed-box-edge0-behavior0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,10,15,7]},"topology":null,"values":{"attributes":{"blocks":[[352173.9822852112,352173.9822852112,21632188.503961973]],"n":114},"fillet_point_step":[0.00001]}}
|
||||
{"case":"attributed-box-edge0-behavior1","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,10,15,7]},"topology":null,"values":{"attributes":{"blocks":[[352190.9822852112,352190.9822852112,21633222.503961973]],"n":114},"fillet_point_step":[0.00001]}}
|
||||
{"case":"attributed-box-edge0-behavior2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,10,15,7]},"topology":null,"values":{"attributes":{"blocks":[[352197.9822852112,352197.9822852112,21633766.503961973]],"n":114},"fillet_point_step":[0.00001]}}
|
||||
@@ -56,7 +56,7 @@
|
||||
{"case":"attributed-step-edge9-r1","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,14,21,9]},"topology":null,"values":{"attributes":{"blocks":[[518859.2787837579,518859.2787837579,44576274.79263492]],"n":162},"fillet_point_step":[0.00001]}}
|
||||
{"case":"attributed-variable-box-edge0-1-to-2","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,10,15,7]},"topology":null,"values":{"attributes":{"blocks":[[352177.32784037106,352177.32784037106,21632479.256082594]],"n":114},"fillet_point_step":[0.00001]}}
|
||||
{"case":"attributed-variable-box-edge6-2-to-1","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,10,15,7]},"topology":null,"values":{"attributes":{"blocks":[[352165.32784038194,352165.32784038194,21631500.77399957]],"n":114},"fillet_point_step":[0.00001]}}
|
||||
{"case":"big-rad-filleted_box-r1.0-top-edges","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,20,36,18],"mass_status":[0],"result_topology":{"blocks":[[281283,35317521],[285988,37334239],[211429,16888283]],"n":668}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[564.2389452228085,983.5015547299157,983.5015547299157,5.038410057395571,5.000000000000004,4.999999925784341,65212.870373066624,65652.9430384423,65652.94978056214,24587.53852411162,24776.42022262525,24776.420624076964],"result_geometry":{"blocks":[[1043.909543499877,1043.909543499877,132801.34831244644],[786.6137295003689,786.6137295003689,92148.64887494135],[548.8554048175439,548.8554048175439,83452.93188722912],[707.0839096995005,708.6839096995004,71698.26219582556],[75.48697566657812,75.48697566657812,960.1950347119977]],"n":1052},"selected_edge_before":{"blocks":[[112569.0,112569.0,7262465.274761933]],"n":136}}}
|
||||
{"case":"big-rad-filleted_box-r1.0-top-edges","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,20,36,18],"mass_status":[0],"result_topology":{"blocks":[[281291,35318792],[285980,37333166],[211429,16888283]],"n":668}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[564.238945222809,983.5015547299164,983.5015547299164,5.038410057395567,5.000000000000008,4.999999925784339,65212.8703730667,65652.94303844232,65652.94978056221,24587.53852411164,24776.42022262525,24776.420624076974],"result_geometry":{"blocks":[[1043.909543499877,1043.909543499877,132577.34831244644],[786.6137295003684,786.6137295003684,92148.64887494127],[548.8554048175436,548.8554048175436,83452.93188722905],[707.0839096995004,708.6839096995003,71698.26219582555],[75.48697566657789,75.48697566657789,960.1950347119924]],"n":1052},"selected_edge_before":{"blocks":[[112569.0,112569.0,7262465.274761933]],"n":136}}}
|
||||
{"case":"big-rad-filleted_box-r2.0-top-edges","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281192,35301210],[207150,18894363]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[581.1150528699196,991.4160344161737,991.4160344161737,5.000000000000002,4.999999999999986,4.999999975615006,65845.15814429456,65969.77702349689,65969.78339230167,24785.400739525885,24785.40073952599,24785.400860155278],"result_geometry":{"blocks":[[1040.9539390913508,1040.9539390913508,137529.47261491715],[755.3864709086489,755.3864709086489,107054.86402295402],[580.7960876026176,593.5960876026177,48777.44680852571]],"n":676},"selected_edge_before":{"blocks":[[112569.0,112569.0,7262465.274761933]],"n":136}}}
|
||||
{"case":"big-rad-filleted_box-r3.0-top-edges","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281192,35301210],[207150,18894363]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[581.1150528699196,991.4160344161737,991.4160344161737,5.000000000000002,4.999999999999986,4.999999975615006,65845.15814429456,65969.77702349689,65969.78339230167,24785.400739525885,24785.40073952599,24785.400860155278],"result_geometry":{"blocks":[[1040.9539390913508,1040.9539390913508,137529.47261491715],[755.3864709086489,755.3864709086489,107054.86402295402],[580.7960876026176,593.5960876026177,48777.44680852571]],"n":676},"selected_edge_before":{"blocks":[[112569.0,112569.0,7262465.274761933]],"n":136}}}
|
||||
{"case":"big-radius-box_diff_cyl_big_rad-r28-edge12","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,5,7,3],"mass_status":[1007],"result_topology":{"blocks":[[143392,7604201]],"n":110}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[],"result_geometry":{"blocks":[[3031.0901543149744,4114.4964121177445,280772.74380950886]],"n":200},"selected_edge_before":{"blocks":[[14306.057758366,14306.057758366,72955.84024483882]],"n":17}}}
|
||||
@@ -365,13 +365,13 @@
|
||||
{"case":"issue919-cylinder-rim-edge2-blend-curve-g1-r9.99","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1007,2,3,3],"mass_status":[0],"result_topology":{"blocks":[[87159,2825806]],"n":60}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1884.9556065113175,6283.18500796372,6283.18500796372,-1.4726672827265266e-8,1.4726670363343151e-8,9.999999999999998,994837.7456252411,994837.7456252411,314159.4891268237,0.011249309873292779,0.009398701749560834,-0.07608134049405635],"result_geometry":{"blocks":[[527.7040579753087,702.5531233622659,26658.127877841434]],"n":116},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
|
||||
{"case":"issue919-cylinder-rim-edge2-blend-curve-g2-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1007,2,3,3],"mass_status":[0],"result_topology":{"blocks":[[87159,2825806]],"n":60}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1884.9556065113175,6283.18500796372,6283.18500796372,-1.4726672827265266e-8,1.4726670363343151e-8,9.999999999999998,994837.7456252411,994837.7456252411,314159.4891268237,0.011249309873292779,0.009398701749560834,-0.07608134049405635],"result_geometry":{"blocks":[[527.7040579753087,702.5531233622659,26658.127877841434]],"n":116},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
|
||||
{"case":"issue919-cylinder-rim-edge2-blend-curve-g2-r9.99","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1007,2,3,3],"mass_status":[0],"result_topology":{"blocks":[[87159,2825806]],"n":60}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1884.9556065113175,6283.18500796372,6283.18500796372,-1.4726672827265266e-8,1.4726670363343151e-8,9.999999999999998,994837.7456252411,994837.7456252411,314159.4891268237,0.011249309873292779,0.009398701749560834,-0.07608134049405635],"result_geometry":{"blocks":[[527.7040579753087,702.5531233622659,26658.127877841434]],"n":116},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
|
||||
{"case":"issue920-cylinder-rim-edge1-constant-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101236,3961388]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.796376097398,5235.988291000227,5235.988291000227,1.883310151187847e-7,2.0599482613885566e-7,11.500000235372115,947714.0853077865,947713.8663011974,240855.69263077818,0.016178781256665505,0.01479147006091632,-0.038040272280341014],"result_geometry":{"blocks":[[590.9590566994827,737.60927109562,34353.41686048178]],"n":138},"selected_edge_before":{"blocks":[[14095.70132599177,14127.020009198175,70343.74995626012]],"n":17}}}
|
||||
{"case":"issue920-cylinder-rim-edge1-constant-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101236,3961388]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.7963760973983,5235.988291000227,5235.988291000227,1.8833101490165886e-7,2.059948257914543e-7,11.500000235372113,947714.0853077865,947713.8663011974,240855.69263077818,0.01617878124484213,0.014791470053185648,-0.03804027228670748],"result_geometry":{"blocks":[[590.9590566994827,737.60927109562,34353.41686048178]],"n":138},"selected_edge_before":{"blocks":[[14095.70132599177,14127.020009198175,70343.74995626012]],"n":17}}}
|
||||
{"case":"issue920-cylinder-rim-edge1-constant-r9.99","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,5,4],"mass_status":[0],"result_topology":{"blocks":[[130313,6525256]],"n":96}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1571.1549739407485,5237.781041311214,5237.781041311214,1.882665543057572e-7,2.0592431928935601e-7,11.497487105531828,947809.066019011,947808.8470124219,240963.9868810795,0.016178781243650686,0.014791470064092968,-0.03804027240533144],"result_geometry":{"blocks":[[596.9287052557129,789.048870953054,40056.385280241884]],"n":174},"selected_edge_before":{"blocks":[[14095.70132599177,14127.020009198175,70343.74995626012]],"n":17}}}
|
||||
{"case":"issue920-cylinder-rim-edge1-variable-constant-law-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101236,3961388]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.796376097398,5235.988291000227,5235.988291000227,1.883310151187847e-7,2.0599482613885566e-7,11.500000235372115,947714.0853077865,947713.8663011974,240855.69263077818,0.016178781256665505,0.01479147006091632,-0.038040272280341014],"result_geometry":{"blocks":[[590.9590566994827,737.60927109562,34353.41686048178]],"n":138},"selected_edge_before":{"blocks":[[14095.70132599177,14127.020009198175,70343.74995626012]],"n":17}}}
|
||||
{"case":"issue920-cylinder-rim-edge1-variable-constant-law-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101236,3961388]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.7963760973983,5235.988291000227,5235.988291000227,1.8833101490165886e-7,2.059948257914543e-7,11.500000235372113,947714.0853077865,947713.8663011974,240855.69263077818,0.01617878124484213,0.014791470053185648,-0.03804027228670748],"result_geometry":{"blocks":[[590.9590566994827,737.60927109562,34353.41686048178]],"n":138},"selected_edge_before":{"blocks":[[14095.70132599177,14127.020009198175,70343.74995626012]],"n":17}}}
|
||||
{"case":"issue920-cylinder-rim-edge1-variable-constant-law-r9.99","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,5,4],"mass_status":[0],"result_topology":{"blocks":[[130313,6525256]],"n":96}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1571.1549739407485,5237.781041311214,5237.781041311214,1.882665543057572e-7,2.0592431928935601e-7,11.497487105531828,947809.066019011,947808.8470124219,240963.9868810795,0.016178781243650686,0.014791470064092968,-0.03804027240533144],"result_geometry":{"blocks":[[596.9287052557129,789.048870953054,40056.385280241884]],"n":174},"selected_edge_before":{"blocks":[[14095.70132599177,14127.020009198175,70343.74995626012]],"n":17}}}
|
||||
{"case":"issue920-cylinder-rim-edge2-constant-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101242,3857334]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.7963806678379,5235.989024424654,5235.989024424654,-2.0599479760804667e-7,-1.8833099051491674e-7,8.500002269459227,633554.821223871,633555.0402304601,240855.47414735783,-0.004930509781645134,-0.005392948677594708,-0.03804027233582019],"result_geometry":{"blocks":[[488.14921457115923,651.8980941809427,32888.688473480026]],"n":138},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
|
||||
{"case":"issue920-cylinder-rim-edge2-constant-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101242,3857334]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.7963806678383,5235.989024424656,5235.989024424656,-2.059947972606453e-7,-1.8833099001756472e-7,8.500002269459227,633554.8212238712,633555.0402304602,240855.4741473579,-0.004930509755303675,-0.005392948670318698,-0.038040272243961226],"result_geometry":{"blocks":[[488.1492145711593,651.8980941809427,32888.688473480026]],"n":138},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
|
||||
{"case":"issue920-cylinder-rim-edge2-constant-r9.99","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,5,4],"mass_status":[0],"result_topology":{"blocks":[[130319,6421375]],"n":96}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1571.1549777992927,5237.781801224955,5237.781801224955,-2.0592429249187742e-7,-1.882665288112231e-7,8.502515462564519,634068.7746551691,634068.9936617584,240963.76368978285,-0.004930509781670225,-0.0053929487658412765,-0.038040272398055484],"result_geometry":{"blocks":[[624.2477803851453,823.5034052152915,53997.852758887406]],"n":174},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
|
||||
{"case":"issue920-cylinder-rim-edge2-variable-constant-law-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101242,3857334]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.7963806678379,5235.989024424654,5235.989024424654,-2.0599479760804667e-7,-1.8833099051491674e-7,8.500002269459227,633554.821223871,633555.0402304601,240855.47414735783,-0.004930509781645134,-0.005392948677594708,-0.03804027233582019],"result_geometry":{"blocks":[[488.14921457115923,651.8980941809427,32888.688473480026]],"n":138},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
|
||||
{"case":"issue920-cylinder-rim-edge2-variable-constant-law-r10.0","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,4,3],"mass_status":[0],"result_topology":{"blocks":[[101242,3857334]],"n":77}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1570.7963806678383,5235.989024424656,5235.989024424656,-2.059947972606453e-7,-1.8833099001756472e-7,8.500002269459227,633554.8212238712,633555.0402304602,240855.4741473579,-0.004930509755303675,-0.005392948670318698,-0.038040272243961226],"result_geometry":{"blocks":[[488.1492145711593,651.8980941809427,32888.688473480026]],"n":138},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
|
||||
{"case":"issue920-cylinder-rim-edge2-variable-constant-law-r9.99","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,3,5,4],"mass_status":[0],"result_topology":{"blocks":[[130319,6421375]],"n":96}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[1571.1549777992927,5237.781801224955,5237.781801224955,-2.0592429249187742e-7,-1.882665288112231e-7,8.502515462564519,634068.7746551691,634068.9936617584,240963.76368978285,-0.004930509781670225,-0.0053929487658412765,-0.038040272398055484],"result_geometry":{"blocks":[[624.2477803851453,823.5034052152915,53997.852758887406]],"n":174},"selected_edge_before":{"blocks":[[14156.70132599177,14188.020009198175,71124.74995626012]],"n":17}}}
|
||||
{"case":"issue_2020_mitred_put_back_d9_adjusted_topology","deferred":null,"error":null,"ints":{"all_corner_vertex_state":{"blocks":[[52,908]],"n":32},"before_direct_topology":{"blocks":[[78,1076]],"n":41},"corner_adjustment_flags":[0,0,0,1,0,0,2,0,0,3,0,0],"corner_calculation_flags":[0,0,0,0,1,0,0,0,2,0,0,0,3,0,0,0],"offsets_after_state":{"blocks":[[486632,29434852]],"n":108},"offsets_before_state":{"blocks":[[486632,29434852]],"n":108},"prefix_state":{"blocks":[[129520,6117777]],"n":245},"prefix_statuses":[0,0,0,0,0]},"topology":null,"values":{"actual_solver_corner_parameters":{"blocks":[[26.792846803190923,26.792846803190923,453.0330947014537]],"n":28},"all_corner_vertices":{"blocks":[[144.00000000000006,144.00000000000006,952.000000000001]],"n":20},"fillet_point_step":[0.00001],"offsets_after_distances":[-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0],"offsets_before_distances":[-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0],"selected_edge_before":{"blocks":[[56502.00000000001,56502.00000000001,1722373.9091997326]],"n":68}}}
|
||||
{"case":"issue_2020_mitred_put_back_d9_corner_edges","deferred":null,"error":null,"ints":{"all_corner_vertex_state":{"blocks":[[32,612]],"n":32},"before_direct_topology":{"blocks":[[78,1076]],"n":41},"corner_adjustment_flags":[0,0,0,1,0,0,2,0,0,3,0,0],"corner_calculation_flags":[0,0,0,0,1,0,0,0,2,0,0,0,3,0,0,0],"corner_edge_geometry_state":{"blocks":[[56154,1403810]],"n":48},"generated_geometry_state":{"blocks":[[228206,15463960]],"n":132},"offsets_after_state":{"blocks":[[486632,29434852]],"n":108},"offsets_before_state":{"blocks":[[486632,29434852]],"n":108},"prefix_state":{"blocks":[[129520,6117777]],"n":245},"prefix_statuses":[0,0,0,0,0,0,0]},"topology":null,"values":{"actual_solver_corner_parameters":{"blocks":[[26.792846803190923,26.792846803190923,453.0330947014537]],"n":28},"all_corner_vertices":{"blocks":[[276.00000000000034,291.9999999999999,3768.00000000001]],"n":40},"corner_edge_geometry_samples":{"blocks":[[701.094308273315,730.1495971314017,20855.868192437465]],"n":76},"fillet_point_step":[0.00001],"generated_geometry_samples":{"blocks":[[-0.7999999999997469,1.4e+21,-7.000000000000052e+20],[61.60000000000035,2e+20,-1e+20]],"n":272},"offsets_after_distances":[-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0],"offsets_before_distances":[-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0,-3.0],"selected_edge_before":{"blocks":[[56502.00000000001,56502.00000000001,1722373.9091997326]],"n":68}}}
|
||||
@@ -706,9 +706,9 @@
|
||||
{"case":"stubs4-degenerate-bottle-e10-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,26,40,16],"mass_status":[0],"result_topology":{"blocks":[[281457,35341216],[282271,36580789],[241977,24565779]],"n":730}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[33.11819079597675,11.066834056610833,11.066834056610833,-1.7609358703810353e-6,-3.0259935914096315e-6,2.0788625909315117,64.79258549927602,66.6733025969641,5.277123502477886,-0.00010373323274112306,-0.00006130966599405097,0.000020823413685507833],"result_geometry":{"blocks":[[912.7406198383134,996.1574227558623,139865.57295882053],[1710.5690534181747,1900.7696584750158,195545.28934387196],[536.122128285832,587.7649824357395,53296.94403726827],[1116.695548284241,1296.7760762038654,136279.5389856879],[81.3031298885769,99.10219410214427,4292.377944642321]],"n":1112},"selected_edge_before":{"blocks":[[14124.027919677248,14129.830196394449,70404.16195467954]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-bottle-e13-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,26,40,16],"mass_status":[0],"result_topology":{"blocks":[[281457,35341216],[282271,36580789],[241977,24565779]],"n":730}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[33.11819079597675,11.066834056610833,11.066834056610833,-1.7609358703810353e-6,-3.0259935914096315e-6,2.0788625909315117,64.79258549927602,66.6733025969641,5.277123502477886,-0.00010373323274112306,-0.00006130966599405097,0.000020823413685507833],"result_geometry":{"blocks":[[912.7406198383134,996.1574227558623,139865.57295882053],[1710.5690534181747,1900.7696584750158,195545.28934387196],[536.122128285832,587.7649824357395,53296.94403726827],[1116.695548284241,1296.7760762038654,136279.5389856879],[81.3031298885769,99.10219410214427,4292.377944642321]],"n":1112},"selected_edge_before":{"blocks":[[14127.137890774764,14132.830196425228,70408.49505041902]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-bottle-e2-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,26,40,16],"mass_status":[0],"result_topology":{"blocks":[[281457,35341216],[282271,36580789],[241977,24565779]],"n":730}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[33.11819079597675,11.066834056610833,11.066834056610833,-1.7609358703810353e-6,-3.0259935914096315e-6,2.0788625909315117,64.79258549927602,66.6733025969641,5.277123502477886,-0.00010373323274112306,-0.00006130966599405097,0.000020823413685507833],"result_geometry":{"blocks":[[912.7406198383134,996.1574227558623,139865.57295882053],[1710.5690534181747,1900.7696584750158,195545.28934387196],[536.122128285832,587.7649824357395,53296.94403726827],[1116.695548284241,1296.7760762038654,136279.5389856879],[81.3031298885769,99.10219410214427,4292.377944642321]],"n":1112},"selected_edge_before":{"blocks":[[14121.830195910168,14121.830195910168,70458.47793958586]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-brepForFillet2-e5-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,17,8],"mass_status":[0],"result_topology":{"blocks":[[284319,36121624],[75249,2183309]],"n":315}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[1440.2512042078263,3616.228017328615,3616.228017328615,3.4572526876318976,6.96874544266061,11.462739678939666,855608.6103133827,741301.6535002191,324387.4213297927,303890.9378205091,98588.82988258151,79222.6133688387],"result_geometry":{"blocks":[[977.961010051866,1132.158704471907,112679.82359158955],[1036.8292124050079,1132.679254497851,128907.22375615075]],"n":494},"selected_edge_before":{"blocks":[[14104.237249363965,14104.237249363965,70989.17788508131]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-brepForFillet2-e7-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,17,8],"mass_status":[0],"result_topology":{"blocks":[[284315,36028144],[75276,1943882]],"n":315}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[1434.446719545761,3614.981574290599,3614.981574290599,3.458487141241509,6.968101849333976,11.456808441115292,854576.7487812358,740358.908198703,324305.38297009957,303605.7870037983,98562.20396720385,79209.84335560785],"result_geometry":{"blocks":[[914.982144292844,1052.8700587476103,105507.89657827417],[1124.5314332536234,1268.0947969440074,136640.56046809343]],"n":494},"selected_edge_before":{"blocks":[[14135.395411755704,14135.395411755704,71369.33404886544]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-brepForFillet2-e9-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,17,8],"mass_status":[0],"result_topology":{"blocks":[[284315,36122025],[75252,2183684]],"n":315}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[1440.2673071459665,3616.232846225047,3616.232846225047,3.4572613565128156,6.9681461275206615,11.462734211720338,855582.4493567429,741302.5155547927,324361.5489719664,303878.46230592194,98589.12346911957,79198.82032237474],"result_geometry":{"blocks":[[941.846900626913,1095.734815081679,106272.87800830806],[1191.5145200824738,1287.0778837728578,145508.12565509713]],"n":494},"selected_edge_before":{"blocks":[[14144.237249363965,14144.237249363965,71425.17788508131]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-brepForFillet2-e5-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,17,8],"mass_status":[0],"result_topology":{"blocks":[[284319,36121624],[75249,2183309]],"n":315}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[1440.2514087704533,3616.228750038445,3616.228750038445,3.457251172602004,6.96874443703026,11.462741006165695,855608.8549164346,741301.9063565227,324387.4354611304,303890.9642818426,98588.77684098834,79222.60746546565],"result_geometry":{"blocks":[[977.9593770006319,1132.1570714206728,112679.78113225622],[1036.828160581436,1132.6782038029176,128907.1646927588]],"n":494},"selected_edge_before":{"blocks":[[14104.237249363965,14104.237249363965,70989.17788508131]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-brepForFillet2-e7-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,17,8],"mass_status":[0],"result_topology":{"blocks":[[284315,36028144],[75276,1943882]],"n":315}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[1434.4468924323423,3614.9815411983363,3614.9815411983363,3.4584870631799705,6.9681018581584295,11.45680847593723,854576.7452027344,740358.9010593686,324305.3770227939,303605.7854831291,98562.20093185853,79209.84097411254],"result_geometry":{"blocks":[[914.9820844486802,1052.8699989034462,105507.89478294915],[1124.5314068643559,1268.0947705547399,136640.55870717773]],"n":494},"selected_edge_before":{"blocks":[[14135.395411755704,14135.395411755704,71369.33404886544]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-brepForFillet2-e9-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,17,8],"mass_status":[0],"result_topology":{"blocks":[[284315,36122025],[75252,2183684]],"n":315}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[1440.2673124917385,3616.232865303477,3616.232865303477,3.4572613193719812,6.968146164613963,11.462734246179187,855582.4593741557,741302.5220788062,324361.5529561333,303878.4671120746,98589.1222390095,79198.81936566049],"result_geometry":{"blocks":[[941.8468663093593,1095.7347807641254,106272.87711605163],[1191.5144945435786,1287.0778582339628,145508.12373045052]],"n":494},"selected_edge_before":{"blocks":[[14144.237249363965,14144.237249363965,71425.17788508131]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-caps_coons-e5-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,5,6,2],"mass_status":[1007],"result_topology":{"blocks":[[114369,5170962]],"n":103}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[],"result_geometry":{"blocks":[[33.20006041930303,362.35023081673893,6668.477882377478]],"n":164},"selected_edge_before":{"blocks":[[14037.1094889089,14070.282001512544,70183.14505475634]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-nurb_cone-e1-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281552,35353171],[207130,17929604]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[314.8325246121948,213.7213448689033,213.7213448689033,-25.256791575512928,-20.641446371057185,-48.78070940849276,602531.7041465972,647001.3562174569,230178.1359116786,215876.09028929376,263548.4756783311,111869.03168332015],"result_geometry":{"blocks":[[-4492.250713961271,4649.51618583184,-535943.3213095476],[-4052.741456601031,4286.165134468015,-516235.4603248068],[-2714.7015550832602,2771.3320313018985,-230085.89935783745]],"n":676},"selected_edge_before":{"blocks":[[13749.737417808685,14322.262582191315,66614.77086913426]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-nurb_cone-e11-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281552,35353171],[207130,17929604]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[314.8325246121948,213.7213448689033,213.7213448689033,-25.256791575512928,-20.641446371057185,-48.78070940849276,602531.7041465972,647001.3562174569,230178.1359116786,215876.09028929376,263548.4756783311,111869.03168332015],"result_geometry":{"blocks":[[-4492.250713961271,4649.51618583184,-535943.3213095476],[-4052.741456601031,4286.165134468015,-516235.4603248068],[-2714.7015550832602,2771.3320313018985,-230085.89935783745]],"n":676},"selected_edge_before":{"blocks":[[13738.84427117878,14353.15572882122,66458.77782534131]],"n":17}}}
|
||||
@@ -716,10 +716,10 @@
|
||||
{"case":"stubs4-degenerate-nurb_cone-e3-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281552,35353171],[207130,17929604]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[314.8325246121948,213.7213448689033,213.7213448689033,-25.256791575512928,-20.641446371057185,-48.78070940849276,602531.7041465972,647001.3562174569,230178.1359116786,215876.09028929376,263548.4756783311,111869.03168332015],"result_geometry":{"blocks":[[-4492.250713961271,4649.51618583184,-535943.3213095476],[-4052.741456601031,4286.165134468015,-516235.4603248068],[-2714.7015550832602,2771.3320313018985,-230085.89935783745]],"n":676},"selected_edge_before":{"blocks":[[13744.700724671131,14331.299275328869,66625.11707259771]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-nurb_cone-e5-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281552,35353171],[207130,17929604]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[314.8325246121948,213.7213448689033,213.7213448689033,-25.256791575512928,-20.641446371057185,-48.78070940849276,602531.7041465972,647001.3562174569,230178.1359116786,215876.09028929376,263548.4756783311,111869.03168332015],"result_geometry":{"blocks":[[-4492.250713961271,4649.51618583184,-535943.3213095476],[-4052.741456601031,4286.165134468015,-516235.4603248068],[-2714.7015550832602,2771.3320313018985,-230085.89935783745]],"n":676},"selected_edge_before":{"blocks":[[13746.484192903254,14333.515807096746,66632.79245387115]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-nurb_cone-e8-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,16,24,10],"mass_status":[0],"result_topology":{"blocks":[[281552,35353171],[207130,17929604]],"n":440}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[314.8325246121948,213.7213448689033,213.7213448689033,-25.256791575512928,-20.641446371057185,-48.78070940849276,602531.7041465972,647001.3562174569,230178.1359116786,215876.09028929376,263548.4756783311,111869.03168332015],"result_geometry":{"blocks":[[-4492.250713961271,4649.51618583184,-535943.3213095476],[-4052.741456601031,4286.165134468015,-516235.4603248068],[-2714.7015550832602,2771.3320313018985,-230085.89935783745]],"n":676},"selected_edge_before":{"blocks":[[13736.67714277342,14349.32285722658,66464.01751703939]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-test5-e2-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286149,29279394]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[85.68068056302971,378.2896320462491,18376.1619712122],[88.63423681712015,173.94735705977516,4988.654837848823]],"n":404},"selected_edge_before":{"blocks":[[14033.1235,14049.423499999999,70158.1555]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-test5-e3-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286141,29613358]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[92.59197973664538,371.60165077974125,21582.08948024115],[81.40325384112059,180.63533832628286,3655.954013313813]],"n":404},"selected_edge_before":{"blocks":[[14034.1235,14050.423499999999,70167.3055]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-test5-e5-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286142,29613638]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[102.47681984275921,382.38943984881865,23475.246803095986],[126.08764664308549,197.3307525314586,7072.332913603795]],"n":404},"selected_edge_before":{"blocks":[[14052.4235,14052.4235,70331.6555]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-test5-e9-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286145,29532836]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[136.43077066828778,400.93935387465575,31560.32044426053],[92.02990150619084,178.78083850562157,7363.80759763486]],"n":404},"selected_edge_before":{"blocks":[[14056.4235,14056.4235,70328.8055]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-test5-e2-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286149,29279394]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[85.68068516522655,378.28962485244244,18376.163061145715],[88.63424007630162,173.94735361807966,4988.6552368631865]],"n":404},"selected_edge_before":{"blocks":[[14033.1235,14049.423499999999,70158.1555]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-test5-e3-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286141,29613358]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[92.5919820900713,371.60164723693,21582.09004605816],[81.40325931621032,180.63533123359207,3655.954458180061]],"n":404},"selected_edge_before":{"blocks":[[14034.1235,14050.423499999999,70167.3055]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-test5-e5-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286142,29613638]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[102.47681659976956,382.38943660577735,23475.24605930542],[126.08763981341828,197.33074608584062,7072.332436838247]],"n":404},"selected_edge_before":{"blocks":[[14052.4235,14052.4235,70331.6555]],"n":17}}}
|
||||
{"case":"stubs4-degenerate-test5-e9-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[0,11,15,5],"mass_status":[1007],"result_topology":{"blocks":[[286145,29532836]],"n":224}},"topology":null,"values":{"fillet_point_step":[0.001],"mass_properties":[],"result_geometry":{"blocks":[[136.43076299695784,400.9393465658624,31560.31867032784],[92.02989898141172,178.78083612575566,7363.807143274321]],"n":404},"selected_edge_before":{"blocks":[[14056.4235,14056.4235,70328.8055]],"n":17}}}
|
||||
{"case":"stubs4-delete-edge-bottle-e16-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,26,40,16],"mass_status":[0],"result_topology":{"blocks":[[281457,35341216],[282271,36580789],[241977,24565779]],"n":730}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[33.11819079597675,11.066834056610833,11.066834056610833,-1.7609358703810353e-6,-3.0259935914096315e-6,2.0788625909315117,64.79258549927602,66.6733025969641,5.277123502477886,-0.00010373323274112306,-0.00006130966599405097,0.000020823413685507833],"result_geometry":{"blocks":[[912.7406198383134,996.1574227558623,139865.57295882053],[1710.5690534181747,1900.7696584750158,195545.28934387196],[536.122128285832,587.7649824357395,53296.94403726827],[1116.695548284241,1296.7760762038654,136279.5389856879],[81.3031298885769,99.10219410214427,4292.377944642321]],"n":1112},"selected_edge_before":{"blocks":[[14131.330290252932,14137.869747404748,70425.4044659795]],"n":17}}}
|
||||
{"case":"stubs4-delete-edge-bottle-e19-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,26,40,16],"mass_status":[0],"result_topology":{"blocks":[[281457,35341216],[282271,36580789],[241977,24565779]],"n":730}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[33.11819079597675,11.066834056610833,11.066834056610833,-1.7609358703810353e-6,-3.0259935914096315e-6,2.0788625909315117,64.79258549927602,66.6733025969641,5.277123502477886,-0.00010373323274112306,-0.00006130966599405097,0.000020823413685507833],"result_geometry":{"blocks":[[912.7406198383134,996.1574227558623,139865.57295882053],[1710.5690534181747,1900.7696584750158,195545.28934387196],[536.122128285832,587.7649824357395,53296.94403726827],[1116.695548284241,1296.7760762038654,136279.5389856879],[81.3031298885769,99.10219410214427,4292.377944642321]],"n":1112},"selected_edge_before":{"blocks":[[14140.869745985467,14140.869745985467,70500.33847700804]],"n":17}}}
|
||||
{"case":"stubs4-delete-edge-bottle-e27-r0.5","deferred":null,"error":null,"ints":{"api_status_vertex_edge_face_counts":[1001,26,40,16],"mass_status":[0],"result_topology":{"blocks":[[281457,35341216],[282271,36580789],[241977,24565779]],"n":730}},"topology":null,"values":{"fillet_point_step":[0.00001],"mass_properties":[33.11819079597675,11.066834056610833,11.066834056610833,-1.7609358703810353e-6,-3.0259935914096315e-6,2.0788625909315117,64.79258549927602,66.6733025969641,5.277123502477886,-0.00010373323274112306,-0.00006130966599405097,0.000020823413685507833],"result_geometry":{"blocks":[[912.7406198383134,996.1574227558623,139865.57295882053],[1710.5690534181747,1900.7696584750158,195545.28934387196],[536.122128285832,587.7649824357395,53296.94403726827],[1116.695548284241,1296.7760762038654,136279.5389856879],[81.3031298885769,99.10219410214427,4292.377944642321]],"n":1112},"selected_edge_before":{"blocks":[[14051.213183992433,14054.532782269893,70249.37439179729]],"n":17}}}
|
||||
|
||||
+11
-11
@@ -75,7 +75,7 @@
|
||||
{"case":"gen0024","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[1.3725441272977728,0.6862720636488864],"point":{"blocks":[[15.294333106129692,74.24695926186625,277.26166903632435]],"n":80}}}
|
||||
{"case":"gen0025","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.07497759728747988,0.07497759728747988],"point":{"blocks":[[20.200632377815086,45.69462178879397,583.6012086259886]],"n":80}}}
|
||||
{"case":"gen0026","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-351325.87341182795,351325.87341182795],"point":{"blocks":[[-116004.64807679922,1232437.5830538757,-6675641.576840369]],"n":80}}}
|
||||
{"case":"gen0027","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.27063687841080664,0.27063687841080664],"point":{"blocks":[[19.619520279904034,35.31714247266496,447.5149876339198]],"n":80}}}
|
||||
{"case":"gen0027","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.27063687841080664,0.27063687841080664],"point":{"blocks":[[19.619452599694057,35.31708324180408,447.5100656516453]],"n":80}}}
|
||||
{"case":"gen0028","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.9020857020487547,-0.9020857020487547],"point":{"blocks":[[19.032946418962048,40.723307502640814,475.973364731492]],"n":80}}}
|
||||
{"case":"gen0029","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.8626803266400759,-0.8626803266400759],"point":{"blocks":[[45.73091678001725,67.7182218583911,1482.7043233607176]],"n":80}}}
|
||||
{"case":"gen0030","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.14397316956260245,-0.14397316956260245],"point":{"blocks":[[25.5536390461404,33.613732647770306,732.1313875262564]],"n":80}}}
|
||||
@@ -97,7 +97,7 @@
|
||||
{"case":"gen0046","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.32184618316829305,0.32184618316829305],"point":{"blocks":[[19.763840541773273,39.03486785606795,551.1708791831503]],"n":80}}}
|
||||
{"case":"gen0047","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.33312092349014705,-0.33312092349014705],"point":{"blocks":[[7.040340626471737,26.13161034528739,144.9432934426138]],"n":80}}}
|
||||
{"case":"gen0048","deferred":null,"error":null,"ints":{"extend":[0,0],"solver_status":[0],"status":[1006]},"topology":null,"values":{"fillet_point_step":[0.00001],"g1":[0.5,0.8626636515623405],"knots_in":[0.5,0.8626636515623405]}}
|
||||
{"case":"gen0049","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-1.5245077199873365,-1.5245077199873365],"point":{"blocks":[[-14.301965863707672,48.973090396590756,-788.1026734875113]],"n":80}}}
|
||||
{"case":"gen0049","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-1.5245077199873365,-1.5245077199873365],"point":{"blocks":[[-15.073318613688702,48.202897057202925,-770.0017196509854]],"n":80}}}
|
||||
{"case":"gen0050","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.7310627321796721,0.36553136608983605],"point":{"blocks":[[30.468690740854942,56.973252644511575,855.5218606973722]],"n":80}}}
|
||||
{"case":"gen0051","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.19181242535671425,0.19181242535671425],"point":{"blocks":[[167138.68739382955,167166.5633069897,1337537.0199421444]],"n":80}}}
|
||||
{"case":"gen0052","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.00008705041260730905,-0.00008705041260730905],"point":{"blocks":[[373.3856616499212,1329.6637292290825,4075.836177353789]],"n":80}}}
|
||||
@@ -185,7 +185,7 @@
|
||||
{"case":"gen0134","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.15418256711648276,-0.07709128355824138],"point":{"blocks":[[-0.26881742962136523,29.37482073855683,-131.35008253638503]],"n":80}}}
|
||||
{"case":"gen0135","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.9760961831605279,-0.9760961831605279],"point":{"blocks":[[21.025297812363757,101.96423972501205,407.6903925943848]],"n":80}}}
|
||||
{"case":"gen0136","deferred":null,"error":null,"ints":{"extend":[1,0],"solver_status":[0],"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"g1":[0.3021049424762976,0.31372290219633897,0.5],"knots_in":[0.3021049424762976,0.31372290219633897,0.5]}}
|
||||
{"case":"gen0137","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-1.0576790822895008,-1.0576790822895008],"point":{"blocks":[[13.566495429311722,46.98956097137515,253.36975225263933]],"n":80}}}
|
||||
{"case":"gen0137","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-1.0576790822895008,-1.0576790822895008],"point":{"blocks":[[9.335519627322816,46.872479150055376,169.28060844591442]],"n":80}}}
|
||||
{"case":"gen0138","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.6683590933820529,0.6683590933820529],"point":{"blocks":[[19.01514505736307,42.42894026927875,341.8749123784208]],"n":80}}}
|
||||
{"case":"gen0139","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.0,0.0],"point":{"blocks":[[8.569487284056807,25.3198495572189,4.416521455062805]],"n":80}}}
|
||||
{"case":"gen0140","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.4624016120819063,-0.4624016120819063],"point":{"blocks":[[17.173329837472025,41.53570566654,441.65602257301765]],"n":80}}}
|
||||
@@ -211,7 +211,7 @@
|
||||
{"case":"gen0160","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.5496454922726274,0.5496454922726274],"point":{"blocks":[[16.564768931704776,37.759077703189824,298.1480286446076]],"n":80}}}
|
||||
{"case":"gen0161","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[287214.5259962959,287214.5259962959],"point":{"blocks":[[3816288.9089792436,10671278.914155519,38669663.12654833]],"n":80}}}
|
||||
{"case":"gen0162","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.5747792377896995,-0.28738961889484976],"point":{"blocks":[[58.91781798645779,82.23215696733745,1759.4838832143903]],"n":80}}}
|
||||
{"case":"gen0163","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.22976384063762864,-0.22976384063762864],"point":{"blocks":[[9.434962951306456,55.19956767728689,-52.78807811852818]],"n":80}}}
|
||||
{"case":"gen0163","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.22976384063762864,-0.22976384063762864],"point":{"blocks":[[10.720789707704943,55.541616265798496,62.68940729085022]],"n":80}}}
|
||||
{"case":"gen0164","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.2642518931171112,-0.1321259465585556],"point":{"blocks":[[4.149969516948741,26.208704086903612,-16.358239053020796]],"n":80}}}
|
||||
{"case":"gen0165","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[4.737045353992686e-7,2.368522676996343e-7],"point":{"blocks":[[-24857875498.02827,24859818313.83341,-223721042039.0927]],"n":80}}}
|
||||
{"case":"gen0166","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[2.702330611485978e-7,-2.702330611485978e-7],"point":{"blocks":[[89463506600.02216,89463984496.77466,805170923729.6243]],"n":80}}}
|
||||
@@ -219,7 +219,7 @@
|
||||
{"case":"gen0168","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.06236256656271657,0.06236256656271657],"point":{"blocks":[[9.197902206312381,36.806480941533124,24.63894952216701]],"n":80}}}
|
||||
{"case":"gen0169","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[76.04306912264374,-76.04306912264374],"point":{"blocks":[[4425.043245579697,7622.315960265144,150273.22079801888]],"n":80}}}
|
||||
{"case":"gen0170","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.39756283642594914,-0.39756283642594914],"point":{"blocks":[[7.381401640225262,45.80037607571984,-51.355411134648094]],"n":80}}}
|
||||
{"case":"gen0171","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.0,0.0],"point":{"blocks":[[10.99999113096873,39.22679472432958,179.70172715799077]],"n":80}}}
|
||||
{"case":"gen0171","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.0,0.0],"point":{"blocks":[[13.182162664588459,39.48730185470428,338.8025711586965]],"n":80}}}
|
||||
{"case":"gen0172","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[1.6198336950826169,1.6198336950826169],"point":{"blocks":[[26.676211927653018,164.40394865910753,1700.676462754016]],"n":80}}}
|
||||
{"case":"gen0173","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.5376285763084768,0.5376285763084768],"point":{"blocks":[[25024.96152479502,25041.65633388397,301599.58464651764]],"n":80}}}
|
||||
{"case":"gen0174","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[412.1007407754986,-206.0503703877493],"point":{"blocks":[[9594.848434170177,21991.54292662045,376251.5556356339]],"n":80}}}
|
||||
@@ -269,7 +269,7 @@
|
||||
{"case":"gen0218","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.292234297866413,-0.292234297866413],"point":{"blocks":[[-7.816330082841141,77.69700078693428,-348.2666303314022]],"n":80}}}
|
||||
{"case":"gen0219","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-1.1523844362351285,1.1523844362351285],"point":{"blocks":[[20.44696232695649,35.15812098497622,542.6715434809263]],"n":80}}}
|
||||
{"case":"gen0220","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[251641.27250837215,125820.63625418607],"point":{"blocks":[[9928383.970224954,29913211.533604838,70400437.53197363]],"n":80}}}
|
||||
{"case":"gen0221","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.0,-0.0],"point":{"blocks":[[3.775737832024795,28.024394813310053,-29.33682967692149]],"n":80}}}
|
||||
{"case":"gen0221","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.0,-0.0],"point":{"blocks":[[1.6290541023966307,27.93836802816698,-182.4340526090423]],"n":80}}}
|
||||
{"case":"gen0222","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[1.4198678936498121,-1.4198678936498121],"point":{"blocks":[[-42.9066648626919,142.76489644611658,-1120.2077599750735]],"n":80}}}
|
||||
{"case":"gen0223","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-1.1713949832846184,-1.1713949832846184],"point":{"blocks":[[-51.5928380467218,135.56818813521377,-2456.689711846445]],"n":80}}}
|
||||
{"case":"gen0224","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-55.74678722526025,-55.74678722526025],"point":{"blocks":[[-3033.32130164265,6222.781383579647,-114675.7577482022]],"n":80}}}
|
||||
@@ -397,7 +397,7 @@
|
||||
{"case":"gen0346","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.27845437433400994,0.27845437433400994],"point":{"blocks":[[31.77212522902905,38.411249671779615,657.6375855996513]],"n":80}}}
|
||||
{"case":"gen0347","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.0,0.0],"point":{"blocks":[[14.42643481624605,38.898545652461245,419.41384411033823]],"n":80}}}
|
||||
{"case":"gen0348","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.024946036492406145,-0.024946036492406145],"point":{"blocks":[[12.269876550986305,28.630828865536774,93.46571111584737]],"n":80}}}
|
||||
{"case":"gen0349","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.8084930105557193,0.8084930105557193],"point":{"blocks":[[2.381289125567308,39.99897684511395,-279.3346691456838]],"n":80}}}
|
||||
{"case":"gen0349","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.8084930105557193,0.8084930105557193],"point":{"blocks":[[5.155159530096939,39.89145114600119,-205.5706761366323]],"n":80}}}
|
||||
{"case":"gen0350","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.2897156024848271,-0.2897156024848271],"point":{"blocks":[[13.993282952100863,36.242777939961584,208.59173608238274]],"n":80}}}
|
||||
{"case":"gen0351","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[1.0141310825330543,-0.5070655412665271],"point":{"blocks":[[-25.16359507230496,55.04546714619831,-1290.7954915809735]],"n":80}}}
|
||||
{"case":"gen0352","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-24149.909384874732,24149.909384874732],"point":{"blocks":[[10403206.057448285,20812931.965070143,445181663.4602736]],"n":80}}}
|
||||
@@ -425,7 +425,7 @@
|
||||
{"case":"gen0374","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-146.52461555673372,-146.52461555673372],"point":{"blocks":[[-922.2370076909385,10139.270345109735,-48637.117917711366]],"n":80}}}
|
||||
{"case":"gen0375","deferred":null,"error":null,"ints":{"extend":[0,0],"solver_status":[0],"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"g1":[0.059485057875261475],"knots_in":[0.059485057875261475]}}
|
||||
{"case":"gen0376","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.0,-0.0],"point":{"blocks":[[4270251.596985117,7626411.224863285,132446896.57202563]],"n":80}}}
|
||||
{"case":"gen0377","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-498.51383362867233,498.51383362867233],"point":{"blocks":[[-6591.732743439909,13882.56835003608,-141749.00948763467]],"n":80}}}
|
||||
{"case":"gen0377","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-498.51383362867233,498.51383362867233],"point":{"blocks":[[-6592.907111109868,13883.698749003837,-141796.8511512976]],"n":80}}}
|
||||
{"case":"gen0378","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[78.67183682576177,78.67183682576177],"point":{"blocks":[[-1744.8622968961572,1764.273044129071,-122520.53088124155]],"n":80}}}
|
||||
{"case":"gen0379","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.0,0.0],"point":{"blocks":[[-34916167718.30228,34916167763.03917,-279329341603.0519]],"n":80}}}
|
||||
{"case":"gen0380","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.7541805448141334,0.7541805448141334],"point":{"blocks":[[21.84350428542847,47.43949785555593,404.9848034985693]],"n":80}}}
|
||||
@@ -580,7 +580,7 @@
|
||||
{"case":"gen0529","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[1.1508522372415169,-1.1508522372415169],"point":{"blocks":[[1.8325186878033715,56.29865648181925,-340.3186580489356]],"n":80}}}
|
||||
{"case":"gen0530","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.49054485491942723,-0.49054485491942723],"point":{"blocks":[[7.236692566166987,54.34778536135473,-141.78096024015886]],"n":80}}}
|
||||
{"case":"gen0531","deferred":null,"error":null,"ints":{"extend":[1,0],"solver_status":[0],"status":[1006]},"topology":null,"values":{"fillet_point_step":[0.00001],"g1":[0.6278786548543553,0.31650252466469564,0.5],"knots_in":[0.6278786548543553,0.31650252466469564,0.5]}}
|
||||
{"case":"gen0532","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.00018572213244843157,-0.00018572213244843157],"point":{"blocks":[[-6100.370291092273,7799.90204176117,-55775.02201245272]],"n":80}}}
|
||||
{"case":"gen0532","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.00018572213244843157,-0.00018572213244843157],"point":{"blocks":[[-1120.667466564267,3006.97625017855,-11024.48464648686]],"n":80}}}
|
||||
{"case":"gen0533","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.0,-0.0],"point":{"blocks":[[5.606656790097681,107.03761455793614,-340.0649040651823]],"n":80}}}
|
||||
{"case":"gen0534","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[1.5790767629736289,1.5790767629736289],"point":{"blocks":[[2.418010021071451,44.65034770788736,-292.965942919616]],"n":80}}}
|
||||
{"case":"gen0535","deferred":null,"error":null,"ints":{"extend":[0,0],"solver_status":[0],"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"g1":[0.9234478012231815],"knots_in":[0.9234478012231815]}}
|
||||
@@ -628,7 +628,7 @@
|
||||
{"case":"gen0577","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[1.4696712182972984,1.4696712182972984],"point":{"blocks":[[59.1250603695906,76.60130498539112,2085.92718898999]],"n":80}}}
|
||||
{"case":"gen0578","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.590642470336763,0.590642470336763],"point":{"blocks":[[-21.46094174519216,71.6406986203895,-770.8928341376198]],"n":80}}}
|
||||
{"case":"gen0579","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.042121849952598524,0.042121849952598524],"point":{"blocks":[[15.443716508467887,71.02447900078211,212.31784072107814]],"n":80}}}
|
||||
{"case":"gen0580","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.0,-0.0],"point":{"blocks":[[5502762.817410249,10005453.970621428,89687798.79781598]],"n":80}}}
|
||||
{"case":"gen0580","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[-0.0,-0.0],"point":{"blocks":[[5502761.207644772,10005453.890265154,89687681.6219422]],"n":80}}}
|
||||
{"case":"gen0581","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[0.00044238756102644956,-0.00022119378051322478],"point":{"blocks":[[683.8457615253996,690.7548713571667,7601.961915957611]],"n":80}}}
|
||||
{"case":"gen0582","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-308021.79493079573,-308021.79493079573],"point":{"blocks":[[8356020.378702474,12825252.847665178,289398215.1605343]],"n":80}}}
|
||||
{"case":"gen0583","deferred":null,"error":null,"ints":{"status":[1001]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[1.319349557027487e-7,6.596747785137436e-8],"point":{"blocks":[[-719890.7600532864,719916.447547392,-7130325.34823085]],"n":80}}}
|
||||
@@ -642,7 +642,7 @@
|
||||
{"case":"gen0591","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[0.8033147828536416,0.8033147828536416],"point":{"blocks":[[20.885335582722558,58.32648437443985,708.3418753107223]],"n":80}}}
|
||||
{"case":"gen0592","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-1.1624619995951506,-0.5812309997975753],"point":{"blocks":[[-11.35263106406933,74.76900844910355,-1083.98000945865]],"n":80}}}
|
||||
{"case":"gen0593","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[397.32274097089766,-397.32274097089766],"point":{"blocks":[[-3419.3921225439144,8523.359196756446,-166828.8704449418]],"n":80}}}
|
||||
{"case":"gen0594","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[2.0804487010843495e-7,-2.0804487010843495e-7],"point":{"blocks":[[520571.5702657828,520580.4131364739,5108767.744788313]],"n":80}}}
|
||||
{"case":"gen0594","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.001],"offsets":[2.0804487010843495e-7,-2.0804487010843495e-7],"point":{"blocks":[[630551.4862896164,630560.5280039047,6648048.921130304]],"n":80}}}
|
||||
{"case":"gen0595","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-0.9428581744297906,0.9428581744297906],"point":{"blocks":[[22.87616290722158,38.14000613960552,596.9756363945345]],"n":80}}}
|
||||
{"case":"gen0596","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[-329.5127908634342,329.5127908634342],"point":{"blocks":[[-82.45538469442229,10097.70561180192,-54109.53663840378]],"n":80}}}
|
||||
{"case":"gen0597","deferred":null,"error":null,"ints":{"status":[0]},"topology":null,"values":{"fillet_point_step":[0.00001],"offsets":[1.5520477637791908e-7,1.5520477637791908e-7],"point":{"blocks":[[53.13636678092718,55.91301238753362,543.9950381533218]],"n":80}}}
|
||||
|
||||
+5
@@ -1760,6 +1760,10 @@ fn enable_fix(k: &mut Kernel, name: &str) -> Result<(), String> {
|
||||
"cover_refuses_folded_continuation" => &mut k.fixes.cover_refuses_folded_continuation,
|
||||
"cover_wraps_across_closed_seam" => &mut k.fixes.cover_wraps_across_closed_seam,
|
||||
"fillet_refuse_non_manifold_result" => &mut k.fixes.fillet_refuse_non_manifold_result,
|
||||
"fillet_torus_rail_on_axis" => &mut k.fixes.fillet_torus_rail_on_axis,
|
||||
"fillet_trace_from_tangent_point_toward_next" => {
|
||||
&mut k.fixes.fillet_trace_from_tangent_point_toward_next
|
||||
}
|
||||
"analytic_copy_keeps_parameterization" => &mut k.fixes.analytic_copy_keeps_parameterization,
|
||||
"precise_properties_body_length_scale" => &mut k.fixes.precise_properties_body_length_scale,
|
||||
"boolean_wire_edge_keeps_faceuse_shell" => {
|
||||
@@ -1772,6 +1776,7 @@ fn enable_fix(k: &mut Kernel, name: &str) -> Result<(), String> {
|
||||
"stitch_solid_nests_cavities" => &mut k.fixes.stitch_solid_nests_cavities,
|
||||
"ssi_low_angle_start_on_probation" => &mut k.fixes.ssi_low_angle_start_on_probation,
|
||||
"curve_tight_box_interval_knots" => &mut k.fixes.curve_tight_box_interval_knots,
|
||||
"fillet_tangent_point_one_sided_step" => &mut k.fixes.fillet_tangent_point_one_sided_step,
|
||||
"fillet_rails_meet_at_one_end_point" => &mut k.fixes.fillet_rails_meet_at_one_end_point,
|
||||
"fillet_nx1_tangent_side_edge" => &mut k.fixes.fillet_nx1_tangent_side_edge,
|
||||
"fillet_nx1_tangent_corner_edge" => &mut k.fixes.fillet_nx1_tangent_corner_edge,
|
||||
|
||||
+2
-4
@@ -15,9 +15,7 @@ FLOATS = ["sqrt", "cbrt", "hypot", "sin", "cos", "tan", "sin_cos", "asin", "acos
|
||||
"ln", "ln_1p", "log", "log2", "log10", "mul_add", "recip", "to_radians", "to_degrees"]
|
||||
# Per type: (file, type, path) and the methods of that type kept. A name kept on one type opens
|
||||
# nothing on another. Kept: constructors from given values, field reads and writes, and the
|
||||
# kernel's certified queries on a transform (`is_regular`, `row_lengths`, `of_placement`), and the
|
||||
# kernel's maps built from an application's numbers and their composition (`rotation`,
|
||||
# `uniform_scaling`, `reflection`, `concat`), which ops calls rather than computes.
|
||||
# kernel's certified queries on a transform (`is_regular`, `row_lengths`, `of_placement`), which ops calls rather than computes.
|
||||
TYPES = [
|
||||
("geom/vector3d.rs", "Vec3", "solidcore::Vec3",
|
||||
{"new", "from_array", "to_array", "set_uninitialized", "is_initialized", "is_undef",
|
||||
@@ -27,7 +25,7 @@ TYPES = [
|
||||
"component", "component_mut"}),
|
||||
("math.rs", "Transform", "solidcore::Transform",
|
||||
{"translation", "x_axis", "y_axis", "z_axis", "translation_part", "is_regular", "row_lengths",
|
||||
"of_placement", "rotation", "uniform_scaling", "reflection", "concat"}),
|
||||
"of_placement"}),
|
||||
("geom/transform.rs", "Transform", "solidcore::geom::transform::Transform",
|
||||
{"new", "matrix", "type_code", "type_string", "class_string", "is_kind_of", "read_from_db"}),
|
||||
("geom/matrix.rs", "Matrix", "solidcore::geom::matrix::Matrix",
|
||||
|
||||
+1
@@ -127,6 +127,7 @@ disallowed-methods = [
|
||||
{ path = "solidcore::Vec2::max_dimension", reason = "ops computes no geometry (docs/layers.md §4)" },
|
||||
{ path = "solidcore::Vec2::rotate", reason = "ops computes no geometry (docs/layers.md §4)" },
|
||||
{ path = "solidcore::Vec2::unitize", reason = "ops computes no geometry (docs/layers.md §4)" },
|
||||
{ path = "solidcore::Transform::concat", reason = "ops computes no geometry (docs/layers.md §4)" },
|
||||
{ path = "solidcore::Transform::from_axes", reason = "ops computes no geometry (docs/layers.md §4)" },
|
||||
{ path = "solidcore::Transform::transform_point", reason = "ops computes no geometry (docs/layers.md §4)" },
|
||||
{ path = "solidcore::Transform::transform_vector", reason = "ops computes no geometry (docs/layers.md §4)" },
|
||||
|
||||
-184
@@ -1,184 +0,0 @@
|
||||
//! Affine maps from an application's numbers, as the matrices [`crate::Body::transform`] and
|
||||
//! [`crate::curve::transform`] take: rows 0-2 the images of x, y and z, row 3 the translation
|
||||
//! (`p' = p·A + t`), the fourth column `(0, 0, 0, 1)`. A move, a turn about a line, a scale
|
||||
//! about a point, a mirror in a plane, and one map after another. The maps are the kernel's
|
||||
//! ([`solidcore::Transform::translation`], [`solidcore::Transform::rotation`],
|
||||
//! [`solidcore::Transform::uniform_scaling`], [`solidcore::Transform::reflection`],
|
||||
//! [`solidcore::Transform::concat`]); ops checks the numbers and passes them on.
|
||||
|
||||
use solidcore::{Transform, Vec3};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// An affine map in the row-vector convention of [`crate::Body::transform`].
|
||||
pub type Matrix = [[f64; 4]; 4];
|
||||
|
||||
/// Whether every number of `v` is finite.
|
||||
fn finite(v: &[f64]) -> bool {
|
||||
v.iter().all(|x| x.is_finite())
|
||||
}
|
||||
|
||||
/// `v` as the kernel's vector.
|
||||
fn vec3([x, y, z]: [f64; 3]) -> Vec3 {
|
||||
Vec3::new(x, y, z)
|
||||
}
|
||||
|
||||
/// The kernel's map, or [`Error::Argument`] for the numbers it refused.
|
||||
fn taken(map: solidcore::Result<Transform>) -> Result<Matrix> {
|
||||
map.map(|t| t.m).map_err(|_| Error::Argument)
|
||||
}
|
||||
|
||||
/// The move by `by`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Argument`] for a number not finite.
|
||||
pub fn translation(by: [f64; 3]) -> Result<Matrix> {
|
||||
if !finite(&by) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
Ok(Transform::translation(vec3(by)).m)
|
||||
}
|
||||
|
||||
/// The turn by `angle` radians, right-handed about `axis`, about the line through `point`
|
||||
/// along `axis` (any length but none).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Argument`] for a point, axis or angle not finite, or an axis of no length.
|
||||
pub fn rotation(point: [f64; 3], axis: [f64; 3], angle: f64) -> Result<Matrix> {
|
||||
taken(Transform::rotation(vec3(point), vec3(axis), angle))
|
||||
}
|
||||
|
||||
/// The scale by `factor` in every direction about `centre`, which it keeps.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Argument`] for a centre not finite, or a factor not finite and positive (a mirror
|
||||
/// is [`reflection`]).
|
||||
pub fn scaling(centre: [f64; 3], factor: f64) -> Result<Matrix> {
|
||||
if factor.is_nan() || factor <= 0.0 {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
taken(Transform::uniform_scaling(vec3(centre), factor))
|
||||
}
|
||||
|
||||
/// The mirror in the plane through `origin` square to `normal` (any length but none).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Argument`] for an origin or normal not finite, or a normal of no length.
|
||||
pub fn reflection(origin: [f64; 3], normal: [f64; 3]) -> Result<Matrix> {
|
||||
taken(Transform::reflection(vec3(origin), vec3(normal)))
|
||||
}
|
||||
|
||||
/// `first`, then `second` (`p·A1 + t1`, then that `·A2 + t2`): steps stacked into one map.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Argument`] for a map with a number not finite, given or made.
|
||||
pub fn then(first: &Matrix, second: &Matrix) -> Result<Matrix> {
|
||||
if !finite(first.as_flattened()) || !finite(second.as_flattened()) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let both = Transform { m: *first }.concat(&Transform { m: *second }).m;
|
||||
if !finite(both.as_flattened()) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
Ok(both)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::body::REGULAR_RATIO;
|
||||
use solidcore::geom::certified::Verdict;
|
||||
|
||||
/// The mirror in the plane y = 0.
|
||||
const MIRROR_Y: Matrix = [
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, -1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
];
|
||||
|
||||
const IDENTITY: Matrix = [
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
];
|
||||
|
||||
/// `p` under `m`, in the row-vector convention.
|
||||
fn apply(m: &Matrix, p: [f64; 3]) -> [f64; 3] {
|
||||
[0, 1, 2].map(|j| p[0] * m[0][j] + p[1] * m[1][j] + p[2] * m[2][j] + m[3][j])
|
||||
}
|
||||
|
||||
fn near(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
fn regular(m: &Matrix) -> bool {
|
||||
Transform { m: *m }.is_regular(REGULAR_RATIO) == Verdict::Yes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirror_in_y_0_is_the_mirror_matrix_and_twice_is_the_identity() {
|
||||
assert_eq!(reflection([0.0; 3], [0.0, 1.0, 0.0]).unwrap(), MIRROR_Y);
|
||||
// any length of an axis normal, any origin on its plane's side: exact
|
||||
for (origin, normal) in [
|
||||
([3.0, 5.0, -2.0], [0.0, 4.0, 0.0]),
|
||||
([1.0, 2.0, 7.5], [0.0, 0.0, -1.0]),
|
||||
([-6.0, 0.0, 1.0], [2.0, 0.0, 0.0]),
|
||||
] {
|
||||
let m = reflection(origin, normal).unwrap();
|
||||
assert!(regular(&m));
|
||||
assert_eq!(then(&m, &m).unwrap(), IDENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_about_z_takes_x_to_y_and_a_turn_keeps_its_line() {
|
||||
let q = std::f64::consts::FRAC_PI_2;
|
||||
let m = rotation([0.0; 3], [0.0, 0.0, 1.0], q).unwrap();
|
||||
assert!(regular(&m));
|
||||
assert!(near(apply(&m, [1.0, 0.0, 0.0]), [0.0, 1.0, 0.0], 1e-15));
|
||||
assert!(near(apply(&m, [0.0, 1.0, 0.0]), [-1.0, 0.0, 0.0], 1e-15));
|
||||
// about the line through (10, 0, 0) along a slanted axis: the line's points stay
|
||||
let m = rotation([10.0, 0.0, 0.0], [1.0, 2.0, 2.0], 0.7).unwrap();
|
||||
assert!(regular(&m));
|
||||
assert!(near(apply(&m, [10.0, 0.0, 0.0]), [10.0, 0.0, 0.0], 1e-13));
|
||||
assert!(near(apply(&m, [13.0, 6.0, 6.0]), [13.0, 6.0, 6.0], 1e-12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scale_keeps_its_centre_and_moves_stack() {
|
||||
let c = [1.0, -2.0, 3.0];
|
||||
let m = scaling(c, 2.5).unwrap();
|
||||
assert!(regular(&m));
|
||||
assert!(near(apply(&m, c), c, 1e-14));
|
||||
assert!(near(apply(&m, [2.0, -2.0, 3.0]), [3.5, -2.0, 3.0], 1e-14));
|
||||
let (a, b) = ([1.5, -2.0, 3.0], [0.25, 4.0, -1.0]);
|
||||
let sum = [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||
let stacked = then(&translation(a).unwrap(), &translation(b).unwrap()).unwrap();
|
||||
assert_eq!(stacked, translation(sum).unwrap());
|
||||
assert!(regular(&stacked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_not_finite_or_of_no_size_are_refused() {
|
||||
assert_eq!(translation([f64::NAN, 0.0, 0.0]), Err(Error::Argument));
|
||||
assert_eq!(rotation([0.0; 3], [0.0; 3], 1.0), Err(Error::Argument));
|
||||
assert_eq!(
|
||||
rotation([0.0; 3], [0.0, 0.0, 1.0], f64::INFINITY),
|
||||
Err(Error::Argument)
|
||||
);
|
||||
assert_eq!(scaling([0.0; 3], 0.0), Err(Error::Argument));
|
||||
assert_eq!(scaling([0.0; 3], -1.0), Err(Error::Argument));
|
||||
assert_eq!(scaling([0.0; 3], f64::NAN), Err(Error::Argument));
|
||||
assert_eq!(scaling([f64::NAN, 0.0, 0.0], 2.0), Err(Error::Argument));
|
||||
assert_eq!(reflection([0.0; 3], [0.0; 3]), Err(Error::Argument));
|
||||
assert_eq!(
|
||||
reflection([0.0, f64::NAN, 0.0], [0.0, 1.0, 0.0]),
|
||||
Err(Error::Argument)
|
||||
);
|
||||
// a stack whose numbers overflow
|
||||
let far = translation([1e308, 0.0, 0.0]).unwrap();
|
||||
assert_eq!(then(&far, &far), Err(Error::Argument));
|
||||
}
|
||||
}
|
||||
+6
-15
@@ -278,9 +278,8 @@ pub enum Error {
|
||||
}
|
||||
|
||||
/// Why the kernel refuses an offset or a draft ([`Error::OffsetRefused`]), as its
|
||||
/// `OffsetFailure` names it. More reasons get names of their own, so match it with a wildcard.
|
||||
/// `OffsetFailure` names it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum OffsetRefusal {
|
||||
/// The face's offset by `distance` is not proved regular, and no wall size would help:
|
||||
/// the face bends towards that side nowhere the kernel's sizing looked, so no radius of
|
||||
@@ -337,18 +336,10 @@ pub enum OffsetRefusal {
|
||||
/// What the built body fails.
|
||||
what: solidcore::error::offset::Unsoundness,
|
||||
},
|
||||
/// A failure the kernel states only in words so far: a routine's own failure (its phrase
|
||||
/// causes `Failed`, `Missing`, `InvalidValue` and `Inconsistent`), or a reason this version
|
||||
/// of ops does not name yet. Transitional: each gets a variant of its own here once the
|
||||
/// kernel gives it a cause of its own.
|
||||
Phrase {
|
||||
/// The kernel's class of the failure.
|
||||
class: solidcore::error::ErrorKind,
|
||||
/// True when the failure is the kernel's fault, not a fact of the input (a consistency
|
||||
/// check of its own that did not hold): a bug to report, not a refusal.
|
||||
defect: bool,
|
||||
/// The kernel's statement of it.
|
||||
phrase: String,
|
||||
/// A reason this version of ops does not name yet: the kernel's own statement of it.
|
||||
Other {
|
||||
/// The kernel's statement of its reason.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -398,7 +389,7 @@ impl std::fmt::Display for OffsetRefusal {
|
||||
)
|
||||
}
|
||||
OffsetRefusal::Unsound { what } => write!(f, "the body built is unsound ({what:?})"),
|
||||
OffsetRefusal::Phrase { phrase, .. } => f.write_str(phrase),
|
||||
OffsetRefusal::Other { message } => write!(f, "{message}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -18,7 +18,6 @@
|
||||
//! the kernel's entities;
|
||||
//! - `finish`: the result contract, from what the input was (crate-internal: the families use it);
|
||||
//! - [`provenance`]: the hook an application's construction history rides on.
|
||||
//! - [`affine`]: the maps a body or a curve is moved by, from an application's numbers.
|
||||
//!
|
||||
//! The families: [`measure`] (mass properties, area, box, point class, gap samples, the picks'
|
||||
//! distances and normals), [`contact`] (whether bodies meet, their boundary distance),
|
||||
@@ -30,7 +29,6 @@
|
||||
#![deny(clippy::disallowed_methods)]
|
||||
#![cfg_attr(not(test), deny(clippy::float_arithmetic))]
|
||||
|
||||
pub mod affine;
|
||||
pub mod blend;
|
||||
pub mod body;
|
||||
pub mod boolean;
|
||||
|
||||
+46
-61
@@ -39,59 +39,49 @@ use crate::{Error, OffsetRefusal, Result};
|
||||
/// The largest offset distance or wall the operations take.
|
||||
const MAX_DISTANCE: f64 = 1e6;
|
||||
|
||||
/// What [`Error::Unsupported`] says for an offset that moves a vertex past an edge's other end.
|
||||
const EDGE_VANISHES: &str = "a vertex moved past the other end of an edge";
|
||||
|
||||
/// An offset's kernel error as the operation's: a refusal the kernel gives a reason for by that
|
||||
/// reason ([`offset_refused`]); one without by its kind ([`by_kind`]), else as
|
||||
/// [`crate::error::status`].
|
||||
/// An offset's kernel error as the operation's: walls that touch ([`Error::WallsTouch`]), a wall
|
||||
/// whose face's offset folds ([`Error::WallFolds`], with the largest wall the kernel names), a
|
||||
/// vertex moved past an edge's other end ([`Error::Unsupported`]), any other offset refusal
|
||||
/// the kernel gives a reason for as [`Error::OffsetRefused`], its face through `index` (#14783),
|
||||
/// and the rest as [`crate::error::status`].
|
||||
fn status(e: SolidError, index: &dyn Fn(FaceId) -> Option<usize>) -> Error {
|
||||
if let Some(Reason::Offset(o)) = e.failure().map(|f| &f.reason) {
|
||||
return offset_refused(*o, e.entity(), index);
|
||||
}
|
||||
by_kind(e.kind()).unwrap_or_else(|| kernel_status(e))
|
||||
}
|
||||
|
||||
/// The operation's error for an offset refusal of `kind` that has one of its own: walls that
|
||||
/// touch ([`Error::WallsTouch`]), a wall that folds ([`Error::WallFolds`], no largest wall
|
||||
/// named), a vertex moved past an edge's other end ([`Error::Unsupported`]).
|
||||
fn by_kind(kind: ErrorKind) -> Option<Error> {
|
||||
match kind {
|
||||
ErrorKind::OffsetWallsTouch => Some(Error::WallsTouch),
|
||||
ErrorKind::OffsetFolds => Some(Error::WallFolds { largest: None }),
|
||||
ErrorKind::OffsetEdgeVanishes => Some(Error::Unsupported(EDGE_VANISHES)),
|
||||
_ => None,
|
||||
match e.kind() {
|
||||
ErrorKind::OffsetWallsTouch => Error::WallsTouch,
|
||||
// (the kernel's fold kind also covers an offset not certified regular, which is no
|
||||
// fold: its own reason, #14783)
|
||||
ErrorKind::OffsetFolds => match e.failure().map(|f| &f.reason) {
|
||||
Some(Reason::Offset(OffsetFailure::Folds { largest, .. })) => Error::WallFolds {
|
||||
largest: Some(*largest),
|
||||
},
|
||||
Some(Reason::Offset(o @ OffsetFailure::NotCertified { .. })) => {
|
||||
offset_refused(*o, index)
|
||||
}
|
||||
_ => Error::WallFolds { largest: None },
|
||||
},
|
||||
ErrorKind::OffsetEdgeVanishes => {
|
||||
Error::Unsupported("a vertex moved past the other end of an edge")
|
||||
}
|
||||
_ => {
|
||||
let offset = match e.failure().map(|f| &f.reason) {
|
||||
Some(Reason::Offset(o)) => Some(*o),
|
||||
_ => None,
|
||||
};
|
||||
match offset {
|
||||
Some(o) => offset_refused(o, index),
|
||||
None => kernel_status(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kernel's offset refusal `o` as the operation's error: walls that touch
|
||||
/// ([`Error::WallsTouch`]), a wall whose face's offset folds ([`Error::WallFolds`], with the
|
||||
/// largest wall the kernel names), a vertex moved past an edge's other end
|
||||
/// ([`Error::Unsupported`]), and every other reason as [`Error::OffsetRefused`] (#14783): why,
|
||||
/// and the face the failure is about (`entity`, the reason's own unless the kernel set another)
|
||||
/// through `index` (`None` where it names none, or one `index` does not find). A reason this
|
||||
/// version does not name yet takes the error of its class where that has one ([`by_kind`]), as
|
||||
/// it did before it had a reason; it and a phrase are otherwise said by their class and in the
|
||||
/// kernel's words ([`unnamed`], #15035).
|
||||
fn offset_refused(
|
||||
o: OffsetFailure,
|
||||
entity: Option<EntityRef>,
|
||||
index: &dyn Fn(FaceId) -> Option<usize>,
|
||||
) -> Error {
|
||||
let face = match entity {
|
||||
/// The kernel's offset refusal `o` as [`Error::OffsetRefused`]: why, and the face it names
|
||||
/// through `index` (`None` where it names none, or one `index` does not find).
|
||||
fn offset_refused(o: OffsetFailure, index: &dyn Fn(FaceId) -> Option<usize>) -> Error {
|
||||
let face = match o.entity() {
|
||||
Some(EntityRef::Face(f)) => index(f),
|
||||
_ => None,
|
||||
};
|
||||
let why = match o {
|
||||
OffsetFailure::Folds { largest, .. } => {
|
||||
return Error::WallFolds {
|
||||
largest: Some(largest),
|
||||
}
|
||||
}
|
||||
OffsetFailure::WallsTouch { .. } => return Error::WallsTouch,
|
||||
OffsetFailure::EdgeVanishes { .. } => return Error::Unsupported(EDGE_VANISHES),
|
||||
// (the kernel's fold kind also covers an offset not certified regular, which is no
|
||||
// fold: its own reason, #14783)
|
||||
OffsetFailure::NotCertified { distance, .. } => OffsetRefusal::NotCertified { distance },
|
||||
OffsetFailure::Collapses {
|
||||
distance, radius, ..
|
||||
@@ -113,23 +103,19 @@ fn offset_refused(
|
||||
OffsetFailure::ReachUncertified { .. } => OffsetRefusal::ReachUncertified,
|
||||
OffsetFailure::LoopGapUncertified { .. } => OffsetRefusal::LoopGapUncertified,
|
||||
OffsetFailure::Unsound { what } => OffsetRefusal::Unsound { what },
|
||||
// the kernel's phrase causes (`OffsetFailure::is_phrase`), and a reason the kernel adds
|
||||
// later: still classed and said, never a bare status
|
||||
other => match by_kind(other.kind()) {
|
||||
Some(e) => return e,
|
||||
None => unnamed(&other),
|
||||
},
|
||||
// a reason this version does not name (one the kernel adds later; `Folds`, `WallsTouch`
|
||||
// and `EdgeVanishes` come with their own kinds, taken above): still said, never a bare
|
||||
// status
|
||||
other => unnamed(&other),
|
||||
};
|
||||
Error::OffsetRefused { face, why }
|
||||
}
|
||||
|
||||
/// A kernel offset failure ops does not name, as [`OffsetRefusal::Phrase`]: the kernel's class
|
||||
/// of it, whether it is a defect, and its own statement of it.
|
||||
/// A kernel offset refusal ops does not name yet, as [`OffsetRefusal::Other`]: the kernel's
|
||||
/// own statement of it.
|
||||
fn unnamed(o: &OffsetFailure) -> OffsetRefusal {
|
||||
OffsetRefusal::Phrase {
|
||||
class: o.kind(),
|
||||
defect: o.is_defect(),
|
||||
phrase: o.to_string(),
|
||||
OffsetRefusal::Other {
|
||||
message: o.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,9 +435,8 @@ fn refused(r: DraftRefusal) -> Error {
|
||||
/// face the body does not have, a body that is not a valid solid facing out, or a pull in the
|
||||
/// neutral plane for a plane face;
|
||||
/// [`Error::Unsupported`] for a face the kernel cannot draft (what it is), or a face drafted past
|
||||
/// where its neighbours meet; [`Error::NoEffect`] for a draft that moved no face;
|
||||
/// [`Error::OffsetRefused`] for a face the kernel refuses with a reason; the kernel's other
|
||||
/// errors; those of [`finish`].
|
||||
/// where its neighbours meet; [`Error::NoEffect`] for a draft that moved no face; the kernel's
|
||||
/// other errors; those of [`finish`].
|
||||
pub fn draft(
|
||||
body: &Body,
|
||||
order: &Order,
|
||||
@@ -486,7 +471,7 @@ pub fn draft(
|
||||
match draft_surface(k, b, f, &the_draft) {
|
||||
Ok(Ok(s)) => surfaces.push(s),
|
||||
Ok(Err(r)) => return Ok(Err(refused(r))),
|
||||
Err(e) => return Ok(Err(status(e, &in_order(order)))),
|
||||
Err(e) => return Ok(Err(kernel_status(e))),
|
||||
}
|
||||
}
|
||||
// the marks live for this call only: put on the picked faces, read and removed once the
|
||||
|
||||
+6
-86
@@ -394,13 +394,11 @@ fn the_offset_refusals_are_typed() {
|
||||
|
||||
/// Each of the kernel's other offset refusals as [`Error::OffsetRefused`] (#14783): its reason,
|
||||
/// and its face by the index the call's lookup gives, `None` for a face it does not find or a
|
||||
/// refusal that names none. Never the bare status they were before, and never the kernel's text
|
||||
/// in place of a reason (#15035): a routine's own failure keeps its kind and the kernel's phrase.
|
||||
/// refusal that names none. Never the bare status they were before.
|
||||
#[test]
|
||||
fn every_other_offset_refusal_says_why_and_where() {
|
||||
use crate::OffsetRefusal as R;
|
||||
use solidcore::error::offset::{LoopCrossing, OffsetFailure as O, Unsoundness};
|
||||
use solidcore::error::ErrorKind;
|
||||
use solidcore::error::Failure;
|
||||
use solidcore::FaceId;
|
||||
let named = FaceId::from_index(3);
|
||||
@@ -503,78 +501,17 @@ fn every_other_offset_refusal_says_why_and_where() {
|
||||
what: Unsoundness::VolumeUndecided,
|
||||
},
|
||||
),
|
||||
// a routine's own failure, so far only a phrase: its class, whether it is the kernel's
|
||||
// fault, and the kernel's words, as it said them when it arrived as text
|
||||
(
|
||||
O::Failed("the face has no surface"),
|
||||
None,
|
||||
R::Phrase {
|
||||
class: ErrorKind::Failed,
|
||||
defect: false,
|
||||
phrase: "the face has no surface".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
O::Missing("the face's surface"),
|
||||
None,
|
||||
R::Phrase {
|
||||
class: ErrorKind::Missing,
|
||||
defect: false,
|
||||
phrase: "the face's surface is missing".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
O::InvalidValue("the face has no outer face use"),
|
||||
None,
|
||||
R::Phrase {
|
||||
class: ErrorKind::InvalidInput,
|
||||
defect: false,
|
||||
phrase: "invalid input: the face has no outer face use".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
O::Inconsistent("an offset edge has no curve"),
|
||||
None,
|
||||
R::Phrase {
|
||||
class: ErrorKind::Failed,
|
||||
defect: true,
|
||||
phrase: "a consistency check failed: an offset edge has no curve".to_owned(),
|
||||
},
|
||||
),
|
||||
];
|
||||
for (o, face, why) in cases {
|
||||
let got = refused(o);
|
||||
assert_eq!(got, Error::OffsetRefused { face, why }, "{o:?}");
|
||||
assert!(!got.to_string().contains("status"), "{got}");
|
||||
// only a phrase arrives as one: every reason the kernel names is named here too
|
||||
let Error::OffsetRefused { why, .. } = &got else {
|
||||
panic!("not an offset refusal: {o:?}");
|
||||
};
|
||||
assert_eq!(matches!(why, R::Phrase { .. }), o.is_phrase(), "{o:?}");
|
||||
}
|
||||
// the face the kernel sets on a failure whose reason names none (a draft's face without a
|
||||
// surface) is the one said
|
||||
let what = "the face has no surface or no outer face use";
|
||||
let at = Failure::new("test", O::InvalidValue(what))
|
||||
.at(solidcore::EntityRef::Face(named))
|
||||
.into_error();
|
||||
assert_eq!(
|
||||
super::status(at, &index),
|
||||
Error::OffsetRefused {
|
||||
face: Some(7),
|
||||
why: R::Phrase {
|
||||
class: ErrorKind::InvalidInput,
|
||||
defect: false,
|
||||
phrase: format!("invalid input: {what}"),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A reason ops does not name yet (one the kernel adds later) is said by its class and in the
|
||||
/// kernel's words, never a bare status: [`super::unnamed`], the arm every unnamed reason takes,
|
||||
/// keeps the kernel's class, whether it is a defect, and its own statement, and the error says
|
||||
/// it.
|
||||
/// A reason ops does not name yet (one the kernel adds later) is said in the kernel's words,
|
||||
/// never a bare status: [`super::unnamed`], the arm every unnamed reason takes, keeps the
|
||||
/// kernel's own statement, and the error says it.
|
||||
#[test]
|
||||
fn an_unnamed_offset_refusal_keeps_the_kernels_words() {
|
||||
use solidcore::error::offset::OffsetFailure;
|
||||
@@ -582,28 +519,11 @@ fn an_unnamed_offset_refusal_keeps_the_kernels_words() {
|
||||
let why = super::unnamed(&o);
|
||||
assert_eq!(
|
||||
why,
|
||||
crate::OffsetRefusal::Phrase {
|
||||
class: o.kind(),
|
||||
defect: o.is_defect(),
|
||||
phrase: o.to_string(),
|
||||
crate::OffsetRefusal::Other {
|
||||
message: o.to_string()
|
||||
}
|
||||
);
|
||||
assert!(!o.to_string().is_empty());
|
||||
// one whose class has an error of its own (a new reason of the walls-touch, fold or
|
||||
// vanishing-edge kind) keeps taking it, as it did before it had a reason
|
||||
assert_eq!(
|
||||
super::by_kind(solidcore::error::ErrorKind::OffsetWallsTouch),
|
||||
Some(Error::WallsTouch)
|
||||
);
|
||||
assert_eq!(
|
||||
super::by_kind(solidcore::error::ErrorKind::OffsetFolds),
|
||||
Some(Error::WallFolds { largest: None })
|
||||
);
|
||||
assert!(matches!(
|
||||
super::by_kind(solidcore::error::ErrorKind::OffsetEdgeVanishes),
|
||||
Some(Error::Unsupported(_))
|
||||
));
|
||||
assert_eq!(super::by_kind(o.kind()), None);
|
||||
let e = Error::OffsetRefused { face: Some(2), why };
|
||||
assert_eq!(
|
||||
e.to_string(),
|
||||
|
||||
+7
-152
@@ -16,12 +16,10 @@ use solidcore::nurbs::b_spline_curve::{
|
||||
BSplineCurve, BSplineCurveForm, CanonicalData, Ends, KnotType,
|
||||
};
|
||||
use solidcore::nurbs::b_spline_surface::{BSplineSurface, SurfParamType};
|
||||
use solidcore::nurbs::n_sided::{n_sided as kernel_n_sided, NSidedRefusal, MAX_SIDES, MIN_SIDES};
|
||||
use solidcore::primitive::api_curve_ops::make_curves_compatible;
|
||||
use solidcore::query::api_queries::point_distance;
|
||||
use solidcore::surface::surface::Surface;
|
||||
use solidcore::topology::brep::p3::new_brep;
|
||||
use solidcore::topology::brep::p4::stitch_faces;
|
||||
use solidcore::topology::brep::sheet::face_on;
|
||||
use solidcore::topology::Brep;
|
||||
use solidcore::SolidError;
|
||||
@@ -239,8 +237,7 @@ fn compatible(a: BSplineCurve, b: BSplineCurve) -> solidcore::Result<[BSplineCur
|
||||
/// curve, point for point at the same fraction of its range (a rational curve, an arc, stays the
|
||||
/// same curve but not the same parameter: the kernel gives the four common end weights). Three curves bound the same patch
|
||||
/// with its fourth side collapsed to the point where the third curve meets the first (a
|
||||
/// degenerate Coons patch: one side of its box is that corner). Five or more bound the
|
||||
/// certified n-sided fill ([`n_sided`]), whose certificate this drops. Each curve is taken over its
|
||||
/// degenerate Coons patch: one side of its box is that corner). Each curve is taken over its
|
||||
/// natural range (a line from its start to its end, a spline over its knots' domain).
|
||||
///
|
||||
/// The ends must meet within the model's zone tolerance: the kernel's tolerance within which
|
||||
@@ -251,14 +248,15 @@ fn compatible(a: BSplineCurve, b: BSplineCurve) -> solidcore::Result<[BSplineCur
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Argument`] for fewer than three curves, or a curve refused (a circle, an ellipse, a
|
||||
/// traced intersection curve, numbers the kernel refuses); [`Error::BoundaryGap`] for curves
|
||||
/// that do not meet; for five or more, those of [`n_sided`]; the kernel's status.
|
||||
/// traced intersection curve, numbers the kernel refuses); [`Error::Unsupported`] for five or
|
||||
/// more curves (no n-sided patch yet); [`Error::BoundaryGap`] for curves that do not meet; the
|
||||
/// kernel's status.
|
||||
pub fn boundary(curves: &[Curve]) -> Result<Record> {
|
||||
if curves.len() < 3 {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
if curves.len() > 4 {
|
||||
return n_sided(curves).map(|(sheet, _)| sheet);
|
||||
return Err(Error::Unsupported("a patch bounded by five or more curves"));
|
||||
}
|
||||
record(run("surface::boundary", |k| {
|
||||
let mut sides = refused!(splines(k, curves)?);
|
||||
@@ -288,66 +286,6 @@ pub fn boundary(curves: &[Curve]) -> Result<Record> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// The n-sided patch bounded by `curves` (three or more, meeting end to end round one loop in
|
||||
/// the order given, each turned to run on from the one before, as [`boundary`] takes them): the
|
||||
/// kernel's fill, N four-sided faces round a centre, each bounded by halves of two of the curves
|
||||
/// and two inner curves from their middles to the centre, stitched into one sheet; and the
|
||||
/// certified bound within which every curve is the sheet's boundary (`solidcore::nurbs::n_sided`).
|
||||
/// The faces meet along the inner curves within the model's zone tolerance (G0, certified);
|
||||
/// tangency across them is the fill's aim, within its angle tolerance, not certified.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`boundary`] for the curves and their joins; [`Error::Unsupported`] for a rational curve
|
||||
/// (an arc: the fill takes polynomial curves) or more than 64 curves; [`Error::PastTolerance`]
|
||||
/// for a boundary or an inner join not proved within the zone tolerance; the kernel's status.
|
||||
pub fn n_sided(curves: &[Curve]) -> Result<(Record, f64)> {
|
||||
if curves.len() < MIN_SIDES {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
if curves.len() > MAX_SIDES {
|
||||
return Err(Error::Unsupported(
|
||||
"an n-sided patch of more than 64 curves",
|
||||
));
|
||||
}
|
||||
let mut bound = None;
|
||||
let made = run("surface::n_sided", |k| {
|
||||
let mut sides = refused!(splines(k, curves)?);
|
||||
let tol = context_zone_tol_3d(k, None, SmallTopoType::NormalSize);
|
||||
refused!(looped(&mut sides, tol)?);
|
||||
match kernel_n_sided(&sides, tol)? {
|
||||
Ok(fill) => {
|
||||
bound = Some(fill.bound);
|
||||
sheet_of(k, fill.patches)
|
||||
}
|
||||
Err(NSidedRefusal::Rational) => Ok(Err(Error::Unsupported(
|
||||
"a rational curve bounding an n-sided patch",
|
||||
))),
|
||||
Err(NSidedRefusal::PastTolerance { bound }) => Ok(Err(Error::PastTolerance { bound })),
|
||||
Err(NSidedRefusal::Joins { gap }) => Ok(Err(Error::PastTolerance { bound: gap })),
|
||||
}
|
||||
});
|
||||
let sheet = record(made)?;
|
||||
Ok((sheet, bound.ok_or(Error::Exception)?))
|
||||
}
|
||||
|
||||
/// A sheet of one face on each of `patches`, over its whole natural box, stitched along the
|
||||
/// sides they share.
|
||||
fn sheet_of(k: &mut Kernel, patches: Vec<BSplineSurface>) -> solidcore::Result<Out<Brep>> {
|
||||
let mut b = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
|
||||
for s in patches {
|
||||
let s = Surface::from(s);
|
||||
let bx = s.natural_uv_domain_with_nurb(k)?;
|
||||
face_on(k, &mut b, s, &bx)?;
|
||||
}
|
||||
b.refresh_cached_topology();
|
||||
let tol = b.tolerance();
|
||||
if let Err(e) = stitch_faces(k, &mut b, tol, None, false) {
|
||||
return Ok(Err(status(e)));
|
||||
}
|
||||
b.refresh_cached_topology();
|
||||
finish(k, b, Expect::FACES)
|
||||
}
|
||||
|
||||
/// The ruled surface between `a` and `b`, one face: `S(s, t) = (1 - t) a(s) + t b(s)`, `s` the
|
||||
/// same fraction of each curve's natural range (the two first made compatible, exactly, as in
|
||||
/// [`boundary`]), `t` from 0 on `a` to 1 on `b`. So the surface's sides are the two curves
|
||||
@@ -623,7 +561,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_few_curves_and_circles_are_refused_and_five_fill() {
|
||||
fn too_few_or_too_many_curves_and_circles_are_refused() {
|
||||
assert_eq!(
|
||||
boundary(&[line(P[0], P[1]), line(P[1], P[0])]),
|
||||
Err(Error::Argument)
|
||||
@@ -638,8 +576,7 @@ mod tests {
|
||||
let pentagon: Vec<Curve> = (0..5)
|
||||
.map(|i| line(corners[i], corners[(i + 1) % 5]))
|
||||
.collect();
|
||||
// five curves: the n-sided fill, five faces
|
||||
assert_eq!(boundary(&pentagon).unwrap().faces.len(), 5);
|
||||
assert!(matches!(boundary(&pentagon), Err(Error::Unsupported(_))));
|
||||
let circle = Curve::Circle {
|
||||
frame: Frame {
|
||||
origin: [0.0; 3],
|
||||
@@ -653,86 +590,4 @@ mod tests {
|
||||
Err(Error::Argument)
|
||||
);
|
||||
}
|
||||
|
||||
/// The corners of a regular pentagon of radius 10 about the z axis, at height `z(x, y)`.
|
||||
fn pentagon(z: impl Fn(f64, f64) -> f64) -> Vec<[f64; 3]> {
|
||||
(0..5)
|
||||
.map(|i| {
|
||||
let a = std::f64::consts::TAU * f64::from(i) / 5.0;
|
||||
let (x, y) = (10.0 * a.cos(), 10.0 * a.sin());
|
||||
[x, y, z(x, y)]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_regular_pentagon_of_lines_fills_flat() {
|
||||
let p = pentagon(|_, _| 0.0);
|
||||
let sides: Vec<Curve> = (0..5).map(|i| line(p[i], p[(i + 1) % 5])).collect();
|
||||
let (sheet, bound) = n_sided(&sides).unwrap();
|
||||
assert!(bound <= 1e-9, "{bound}");
|
||||
assert_eq!(sheet.faces.len(), 5);
|
||||
// five faces round a centre, stitched along the five inner curves
|
||||
assert_eq!(sheet.edges.iter().filter(|e| e.uses.len() == 2).count(), 5);
|
||||
// planar data, planar faces: every point of every face in z = 0
|
||||
for face in &sheet.faces {
|
||||
for (s, t) in [(0.0, 0.0), (0.3, 0.6), (1.0, 0.5), (0.5, 1.0)] {
|
||||
assert!(at(face, s, t)[2].abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_splines_on_a_saddle_bound_their_patch() {
|
||||
// each side a cubic whose control points lie on z = x y / 50 at thirds of the chord
|
||||
let z = |x: f64, y: f64| x * y / 50.0;
|
||||
let p = pentagon(z);
|
||||
let sides: Vec<Curve> = (0..5)
|
||||
.map(|i| {
|
||||
let (a, b) = (p[i], p[(i + 1) % 5]);
|
||||
let third = |t: f64| {
|
||||
let (x, y) = (a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]));
|
||||
[x, y, z(x, y)]
|
||||
};
|
||||
Curve::Spline {
|
||||
knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0],
|
||||
points: vec![a, third(1.0 / 3.0), third(2.0 / 3.0), b],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let (sheet, bound) = n_sided(&sides).unwrap();
|
||||
assert!(bound <= 1e-6, "{bound}");
|
||||
assert_eq!(sheet.faces.len(), 5);
|
||||
// the corners are vertices of the sheet: its boundary runs through them
|
||||
for corner in &p {
|
||||
assert!(
|
||||
sheet.vertices.iter().any(|v| near(v.point, *corner, 1e-6)),
|
||||
"{corner:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_arcs_on_a_sphere_are_refused() {
|
||||
// five arcs of the circle z = 6 on the sphere of radius 10 (radius 8), as rational
|
||||
// quadratics
|
||||
let half = std::f64::consts::PI / 5.0;
|
||||
let w = half.cos();
|
||||
let arcs: Vec<Curve> = (0..5)
|
||||
.map(|i| {
|
||||
let a = 2.0 * half * f64::from(i);
|
||||
let on = |t: f64, r: f64| [r * t.cos(), r * t.sin(), 6.0];
|
||||
let (s, m, e) = (on(a, 8.0), on(a + half, 8.0 / w), on(a + 2.0 * half, 8.0));
|
||||
Curve::RationalSpline {
|
||||
knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
homogeneous: vec![
|
||||
[s[0], s[1], s[2], 1.0],
|
||||
[m[0] * w, m[1] * w, m[2] * w, w],
|
||||
[e[0], e[1], e[2], 1.0],
|
||||
],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert!(matches!(n_sided(&arcs), Err(Error::Unsupported(_))));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-301
@@ -9,9 +9,7 @@ use solidcore::nurbs::crv_fit::p1::approx_knots;
|
||||
use solidcore::nurbs::geom::Rectangle;
|
||||
use solidcore::nurbs::knots::{clamped_uniform, KnotVector, Side};
|
||||
use solidcore::nurbs::math::{CPoint, NOW};
|
||||
use solidcore::nurbs::srf_basic::{
|
||||
falloff_weight as kernel_falloff, Direction, FalloffShape, Surface as NurbSurface,
|
||||
};
|
||||
use solidcore::nurbs::srf_basic::{Direction, Surface as NurbSurface};
|
||||
use solidcore::nurbs::srf_deviation::certified_distance;
|
||||
use solidcore::Vec3;
|
||||
use solidcore_record::brep::{
|
||||
@@ -81,144 +79,6 @@ pub fn move_cvs(sheet: &Brep, moves: &[((usize, usize), [f64; 3])]) -> Result<Br
|
||||
}))
|
||||
}
|
||||
|
||||
/// What [`move_net`] moves: whole rows (u indices), whole columns (v indices) or single control
|
||||
/// points `(i, j)`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Picks {
|
||||
/// Every point of these u indices.
|
||||
Rows(Vec<usize>),
|
||||
/// Every point of these v indices.
|
||||
Columns(Vec<usize>),
|
||||
/// These points, `(u index, v index)`.
|
||||
Cvs(Vec<(usize, usize)>),
|
||||
}
|
||||
|
||||
/// The shape of a soft selection's falloff over the index distance `d` from the picks, with
|
||||
/// `s = d / (radius + 1)`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Shape {
|
||||
/// `1 - s`.
|
||||
Linear,
|
||||
/// `1 - (3 s² - 2 s³)`: flat at the picks and at the radius's edge.
|
||||
Smooth,
|
||||
}
|
||||
|
||||
/// A soft selection: the points within `radius` index steps of the picks move too, by the
|
||||
/// weight [`Shape`] gives their distance.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Falloff {
|
||||
/// The most index steps from a pick a point moves at: 0 moves the picks alone.
|
||||
pub radius: u32,
|
||||
/// How the weight falls from 1 at the picks.
|
||||
pub shape: Shape,
|
||||
}
|
||||
|
||||
/// The weight of a point `d` index steps from the nearest pick: 1 at a pick, falling by
|
||||
/// `falloff`'s shape, 0 past its radius (and for every point off the picks without a falloff).
|
||||
#[must_use]
|
||||
pub fn falloff_weight(falloff: Option<Falloff>, d: usize) -> f64 {
|
||||
let Some(f) = falloff else {
|
||||
return if d == 0 { 1.0 } else { 0.0 };
|
||||
};
|
||||
let Ok(d) = u32::try_from(d) else {
|
||||
return 0.0;
|
||||
};
|
||||
let shape = match f.shape {
|
||||
Shape::Linear => FalloffShape::Linear,
|
||||
Shape::Smooth => FalloffShape::Smooth,
|
||||
};
|
||||
kernel_falloff(shape, f.radius, d)
|
||||
}
|
||||
|
||||
/// The weight table of a `rows` by `cols` net (row by row, as [`Net::points`]): each point's
|
||||
/// [`falloff_weight`] at its index distance from the nearest pick. A row pick's distance is
|
||||
/// along u (`|i - row|`), a column pick's along v (`|j - column|`), a point pick's the larger of
|
||||
/// the two (the square ring of the net around it).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Argument`] for no picks, a pick past the net, or a net of no points or more than
|
||||
/// [`REBUILD_MAX_SAMPLES`].
|
||||
pub fn net_weights(
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
picks: &Picks,
|
||||
falloff: Option<Falloff>,
|
||||
) -> Result<Vec<f64>> {
|
||||
let total = rows
|
||||
.checked_mul(cols)
|
||||
.filter(|&n| n > 0 && n <= REBUILD_MAX_SAMPLES)
|
||||
.ok_or(Error::Argument)?;
|
||||
let (empty, inside) = match picks {
|
||||
Picks::Rows(p) => (p.is_empty(), p.iter().all(|&i| i < rows)),
|
||||
Picks::Columns(p) => (p.is_empty(), p.iter().all(|&j| j < cols)),
|
||||
Picks::Cvs(p) => (p.is_empty(), p.iter().all(|&(i, j)| i < rows && j < cols)),
|
||||
};
|
||||
if empty || !inside {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let nearest = |i: usize, j: usize| -> usize {
|
||||
let d = match picks {
|
||||
Picks::Rows(p) => p.iter().map(|&r| i.abs_diff(r)).min(),
|
||||
Picks::Columns(p) => p.iter().map(|&c| j.abs_diff(c)).min(),
|
||||
Picks::Cvs(p) => p
|
||||
.iter()
|
||||
.map(|&(a, b)| i.abs_diff(a).max(j.abs_diff(b)))
|
||||
.min(),
|
||||
};
|
||||
d.unwrap_or(usize::MAX)
|
||||
};
|
||||
let mut table = Vec::new();
|
||||
table
|
||||
.try_reserve_exact(total)
|
||||
.map_err(|_| Error::Argument)?;
|
||||
for i in 0..rows {
|
||||
for j in 0..cols {
|
||||
table.push(falloff_weight(falloff, nearest(i, j)));
|
||||
}
|
||||
}
|
||||
Ok(table)
|
||||
}
|
||||
|
||||
/// `sheet` with the picked control points moved by `delta`, and with a `falloff` the points
|
||||
/// around them by their weight of it ([`net_weights`]): the kernel moves each point of weight
|
||||
/// `t` by `t delta` (`Surface::displace_cpts`). The knots and degrees are kept; the result is a
|
||||
/// new one-face sheet on the moved surface, its whole box.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`control_net`] and [`net_weights`]; [`Error::Argument`] for a `delta` not finite or
|
||||
/// past the coordinate bound, or a moved point past it; the kernel's status otherwise.
|
||||
pub fn move_net(
|
||||
sheet: &Brep,
|
||||
picks: &Picks,
|
||||
delta: [f64; 3],
|
||||
falloff: Option<Falloff>,
|
||||
) -> Result<Brep> {
|
||||
if !coord_ok(&delta) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let ((u, v), points) = untrimmed(sheet)?;
|
||||
let net = control_net(sheet)?;
|
||||
let weights = net_weights(net.rows, net.cols, picks, falloff)?;
|
||||
let moves: Vec<((usize, usize), f64)> = (0..net.rows)
|
||||
.flat_map(|i| (0..net.cols).map(move |j| (i, j)))
|
||||
.zip(weights)
|
||||
.filter(|&(_, t)| t > 0.0)
|
||||
.collect();
|
||||
let mut surface = kernel_surface(u, v, points)?;
|
||||
let moved = record(run("surface_cvs::move_net", |k| {
|
||||
surface.displace_cpts(&moves, Vec3::new(delta[0], delta[1], delta[2]))?;
|
||||
sheet_of(k, BSplineSurface::from_gw_nurb(&surface))
|
||||
}))?;
|
||||
if !control_net(&moved)?
|
||||
.points
|
||||
.iter()
|
||||
.all(|p| coord_ok(p.as_slice()))
|
||||
{
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// `sheet`'s surface refitted to degrees `(deg_u, deg_v)` with `(spans_u, spans_v)` uniform
|
||||
/// spans on `[0, 1]²`, and the kernel's certified bound of how far the refit is from the
|
||||
/// surface at every parameter ([`certified_distance`]), kept when within `tol`. The surface,
|
||||
@@ -514,166 +374,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_move_moves_exactly_that_row() {
|
||||
let s = long_sheet();
|
||||
let net = control_net(&s).unwrap();
|
||||
let delta = [0.0, 0.0, 1.0];
|
||||
let last = net.rows - 1;
|
||||
for falloff in [
|
||||
None,
|
||||
Some(Falloff {
|
||||
radius: 0,
|
||||
shape: Shape::Smooth,
|
||||
}),
|
||||
] {
|
||||
let moved = move_net(&s, &Picks::Rows(vec![last]), delta, falloff).unwrap();
|
||||
let after = control_net(&moved).unwrap();
|
||||
assert_eq!((after.rows, after.cols), (net.rows, net.cols));
|
||||
for (n, (p, q)) in net.points.iter().zip(&after.points).enumerate() {
|
||||
if n / net.cols == last {
|
||||
assert_eq!(*q, [p[0], p[1], p[2] + 1.0], "point {n}");
|
||||
} else {
|
||||
assert_eq!(p, q, "point {n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// a column the same way
|
||||
let moved = move_net(&s, &Picks::Columns(vec![0]), delta, None).unwrap();
|
||||
let after = control_net(&moved).unwrap();
|
||||
for (n, (p, q)) in net.points.iter().zip(&after.points).enumerate() {
|
||||
if n % net.cols == 0 {
|
||||
assert_eq!(*q, [p[0], p[1], p[2] + 1.0], "point {n}");
|
||||
} else {
|
||||
assert_eq!(p, q, "point {n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falloff_weights_are_one_at_the_pick_zero_past_the_radius_and_monotone() {
|
||||
assert_eq!(falloff_weight(None, 0), 1.0);
|
||||
assert_eq!(falloff_weight(None, 1), 0.0);
|
||||
for shape in [Shape::Linear, Shape::Smooth] {
|
||||
let f = Some(Falloff { radius: 3, shape });
|
||||
let w: Vec<f64> = (0..8).map(|d| falloff_weight(f, d)).collect();
|
||||
assert_eq!(w[0], 1.0, "{shape:?}");
|
||||
assert!(
|
||||
w[1..=3].iter().all(|&x| x > 0.0 && x < 1.0),
|
||||
"{shape:?}: {w:?}"
|
||||
);
|
||||
assert!(w[4..].iter().all(|&x| x == 0.0), "{shape:?}: {w:?}");
|
||||
assert!(
|
||||
w.windows(2).all(|p| p[1] < p[0] || p[1] == 0.0),
|
||||
"{shape:?}: {w:?}"
|
||||
);
|
||||
// the table: a point pick's distance is its square ring on the net
|
||||
let table = net_weights(9, 7, &Picks::Cvs(vec![(4, 3)]), f).unwrap();
|
||||
assert_eq!(table.len(), 63);
|
||||
for i in 0..9_usize {
|
||||
for j in 0..7_usize {
|
||||
let d = i.abs_diff(4).max(j.abs_diff(3));
|
||||
assert_eq!(table[i * 7 + j], w[d], "{shape:?} at ({i}, {j})");
|
||||
}
|
||||
}
|
||||
}
|
||||
// the smooth shape is flat at the pick: it falls less over the first step
|
||||
let (linear, smooth) = (
|
||||
falloff_weight(
|
||||
Some(Falloff {
|
||||
radius: 3,
|
||||
shape: Shape::Linear,
|
||||
}),
|
||||
1,
|
||||
),
|
||||
falloff_weight(
|
||||
Some(Falloff {
|
||||
radius: 3,
|
||||
shape: Shape::Smooth,
|
||||
}),
|
||||
1,
|
||||
),
|
||||
);
|
||||
assert!(smooth > linear, "{smooth} {linear}");
|
||||
// rows and columns by their one index
|
||||
let rows = net_weights(
|
||||
5,
|
||||
3,
|
||||
&Picks::Rows(vec![0]),
|
||||
Some(Falloff {
|
||||
radius: 1,
|
||||
shape: Shape::Linear,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![1.0, 1.0, 1.0, 0.5, 0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
|
||||
);
|
||||
// bad picks
|
||||
assert_eq!(
|
||||
net_weights(5, 3, &Picks::Rows(vec![]), None),
|
||||
Err(Error::Argument)
|
||||
);
|
||||
assert_eq!(
|
||||
net_weights(5, 3, &Picks::Rows(vec![5]), None),
|
||||
Err(Error::Argument)
|
||||
);
|
||||
assert_eq!(
|
||||
net_weights(5, 3, &Picks::Columns(vec![3]), None),
|
||||
Err(Error::Argument)
|
||||
);
|
||||
assert_eq!(
|
||||
net_weights(5, 3, &Picks::Cvs(vec![(0, 3)]), None),
|
||||
Err(Error::Argument)
|
||||
);
|
||||
assert_eq!(
|
||||
net_weights(0, 3, &Picks::Rows(vec![0]), None),
|
||||
Err(Error::Argument)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_soft_move_moves_each_point_by_its_weight() {
|
||||
let s = bicubic();
|
||||
let net = control_net(&s).unwrap();
|
||||
let f = Some(Falloff {
|
||||
radius: 1,
|
||||
shape: Shape::Linear,
|
||||
});
|
||||
let picks = Picks::Cvs(vec![(1, 1)]);
|
||||
let moved = move_net(&s, &picks, [0.0, 0.0, 2.0], f).unwrap();
|
||||
let after = control_net(&moved).unwrap();
|
||||
let weights = net_weights(net.rows, net.cols, &picks, f).unwrap();
|
||||
for (n, ((p, q), w)) in net
|
||||
.points
|
||||
.iter()
|
||||
.zip(&after.points)
|
||||
.zip(&weights)
|
||||
.enumerate()
|
||||
{
|
||||
assert_eq!((q[0], q[1]), (p[0], p[1]), "point {n}");
|
||||
assert!(
|
||||
(q[2] - (p[2] + 2.0 * w)).abs() <= 1e-15 * (1.0 + p[2].abs()),
|
||||
"point {n}: {p:?} to {q:?}, weight {w}"
|
||||
);
|
||||
}
|
||||
// the pick by the whole delta, its ring by half, the rest not at all
|
||||
assert_eq!(weights[5], 1.0);
|
||||
assert_eq!(weights[0], 0.5);
|
||||
assert_eq!(weights[3 * 4 + 3], 0.0);
|
||||
assert_eq!(after.points[15], net.points[15]);
|
||||
// bad moves
|
||||
assert_eq!(
|
||||
move_net(&s, &picks, [f64::NAN, 0.0, 0.0], f),
|
||||
Err(Error::Argument)
|
||||
);
|
||||
assert_eq!(
|
||||
move_net(&s, &Picks::Cvs(vec![(4, 0)]), [0.0; 3], f),
|
||||
Err(Error::Argument)
|
||||
);
|
||||
}
|
||||
|
||||
/// The Coons sheet of four bent cubic Bézier sides: a bicubic Bézier patch.
|
||||
fn bicubic() -> Brep {
|
||||
let side = |points: [[f64; 3]; 4]| Curve::Spline {
|
||||
|
||||
+1
-61
@@ -7,7 +7,7 @@ use std::io::Read as _;
|
||||
use serde_json::Value;
|
||||
use solidcore_ops::boolean::{boolean, imprint, union_all};
|
||||
use solidcore_ops::sweep::{extrude, revolve, Curve, DraftCorner};
|
||||
use solidcore_ops::{make_box, make_sphere, session, Body, BooleanOp, CheckLevel, Error};
|
||||
use solidcore_ops::{make_box, session, Body, BooleanOp, CheckLevel, Error};
|
||||
use solidcore_record::brep::Brep as Record;
|
||||
|
||||
fn all_fixes() {
|
||||
@@ -65,66 +65,6 @@ fn boxes_touching_along_an_edge_union_to_a_closed_body() {
|
||||
assert_eq!(u.solid_count().unwrap(), 2);
|
||||
}
|
||||
|
||||
/// A 20 mm box with a closed void at its middle: a 10 mm cube void, or a sphere void of radius 5.
|
||||
fn box_with_void(cube: bool) -> Body {
|
||||
let tool = if cube {
|
||||
make_box([5.0, 5.0, 5.0], [10.0, 10.0, 10.0]).unwrap()
|
||||
} else {
|
||||
make_sphere([10.0, 10.0, 10.0], 5.0).unwrap()
|
||||
};
|
||||
let b = make_box([0.0, 0.0, 0.0], [20.0, 20.0, 20.0]).unwrap();
|
||||
boolean(b, tool, BooleanOp::Subtract).unwrap().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_union_that_fills_a_void_is_the_whole_box() {
|
||||
all_fixes();
|
||||
// Every face of the void is deleted, and the region they joined is material. The tool's
|
||||
// faces lie on the void's, or inside the box's material, so none of them is copied in.
|
||||
let fillers = [
|
||||
(true, make_box([5.0, 5.0, 5.0], [10.0, 10.0, 10.0]).unwrap()),
|
||||
(true, make_box([4.0, 4.0, 4.0], [12.0, 12.0, 12.0]).unwrap()),
|
||||
(true, make_box([5.0, 4.0, 4.0], [12.0, 12.0, 12.0]).unwrap()),
|
||||
(false, make_sphere([10.0, 10.0, 10.0], 5.0).unwrap()),
|
||||
(false, make_sphere([10.0, 10.0, 10.0], 6.0).unwrap()),
|
||||
(
|
||||
false,
|
||||
make_box([5.0, 5.0, 5.0], [10.0, 10.0, 10.0]).unwrap(),
|
||||
),
|
||||
];
|
||||
for (n, (cube, filler)) in fillers.into_iter().enumerate() {
|
||||
let u = boolean(box_with_void(cube), filler, BooleanOp::Union)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(near(volume(&u), 8000.0), "filler {n}: {}", volume(&u));
|
||||
assert_eq!(u.counts().unwrap(), [6, 12, 8], "filler {n}");
|
||||
assert!(u.is_manifold_solid().unwrap(), "filler {n}");
|
||||
assert!(u.to_record().is_ok(), "filler {n}");
|
||||
}
|
||||
// A filler that leaves part of the void keeps that part: 20³ − 10 · 10 · 1.
|
||||
let part = make_box([6.0, 5.0, 5.0], [10.0, 10.0, 10.0]).unwrap();
|
||||
let u = boolean(box_with_void(true), part, BooleanOp::Union)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(near(volume(&u), 7900.0), "{}", volume(&u));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cube_on_a_void_is_kept_out_by_a_difference_and_shares_nothing_with_it() {
|
||||
all_fixes();
|
||||
let cube = || make_box([5.0, 5.0, 5.0], [10.0, 10.0, 10.0]).unwrap();
|
||||
// The faces bound material on opposite sides: a difference keeps them, so the box is
|
||||
// unchanged, and an intersection deletes them and leaves nothing.
|
||||
let d = boolean(box_with_void(true), cube(), BooleanOp::Subtract)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(near(volume(&d), 7000.0), "{}", volume(&d));
|
||||
assert_eq!(d.counts().unwrap(), [12, 24, 16]);
|
||||
assert!(boolean(box_with_void(true), cube(), BooleanOp::Intersect)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imprint_cuts_the_faces_the_tool_crosses() {
|
||||
all_fixes();
|
||||
|
||||
+32
-25
@@ -1180,11 +1180,11 @@ impl FilletSolver {
|
||||
/// `Kernel::fixes.fillet_chamfer_cone_stored_as_cone` as well (WR-572) that revolution
|
||||
/// is stored as the cone `CopyAndAddAnalytics` finds in its NURBS form, the rails made on
|
||||
/// it.
|
||||
/// Not upstream: a section centred on the arc's axis has its rail points turned into
|
||||
/// the half-planes through the edge's ends and takes the edge's start for the nearest
|
||||
/// point (WR-422); a start rail point within the solver's tolerance of the arc's axis
|
||||
/// is moved onto the axis (WR-592); the span is the turn from the start section to the
|
||||
/// end one, without `IsArc`'s start angle (WR-568).
|
||||
/// With `Kernel::fixes.fillet_torus_center_on_axis` (WR-422), a section centred on the
|
||||
/// arc's axis has its rail points turned into the half-planes through the edge's ends and
|
||||
/// takes the edge's start for the nearest point.
|
||||
/// With `Kernel::fixes.fillet_torus_rail_on_axis` (WR-592), a start rail point within
|
||||
/// the solver's tolerance of the arc's axis is moved onto the axis.
|
||||
///
|
||||
/// `SM_ERR` when the generator is not circular, the edge curve is not an arc, the
|
||||
/// radius is tiny or changes, or the nearest point on the edge is not unique; those
|
||||
@@ -1214,8 +1214,8 @@ impl FilletSolver {
|
||||
/// and `IsArc` gates. The frame is the class axis with its origin where the axis meets
|
||||
/// the plane through the edge's start square to it, its x axis the world x (y when the
|
||||
/// axis is within `acos(0.9)` of x) made square to the axis; there is no `IsArc` start
|
||||
/// angle, so the span is the turn from the start section, as in
|
||||
/// [`FilletSolver::torus_fillet_geom_parts`].
|
||||
/// angle, so the span is the turn from the start section (as with
|
||||
/// `fillet_torus_span_from_start_section`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -1255,12 +1255,11 @@ impl FilletSolver {
|
||||
|
||||
// no work - not a circular cross-section on a circular arc (with
|
||||
// `fixes.fillet_chamfer_cone_on_arc`, or a linear one: its revolution is a cone)
|
||||
// Side effect: get circular arc RefFrame, Radius, StartAng, and EndAng (only the
|
||||
// frame is used: the span is the turn from the start section, WR-568).
|
||||
// Side effect: get circular arc RefFrame, Radius, StartAng, and EndAng.
|
||||
let section = fsg_type(s)?;
|
||||
let linear_section =
|
||||
k.fixes.fillet_chamfer_cone_on_arc && section == FilletSurfaceGeneratorType::Linear;
|
||||
let ref_frame = if let Some(class) = class {
|
||||
let (ref_frame, start_ang) = if let Some(class) = class {
|
||||
// WR-669: the circular or linear cross-section turned about the faces' common
|
||||
// axis (the class's); the frame's origin: the centre of the edge's circle, where
|
||||
// the axis meets the edge's plane
|
||||
@@ -1276,17 +1275,17 @@ impl FilletSolver {
|
||||
let x = x.unitize()?;
|
||||
let mut frame = Axis2Placement::default();
|
||||
frame.set_canonical(centre, x, z.cross(x))?;
|
||||
frame
|
||||
(frame, 0.0)
|
||||
} else {
|
||||
if section != FilletSurfaceGeneratorType::Circular && !linear_section {
|
||||
return Err(sm_err());
|
||||
}
|
||||
let Some((ref_frame, _arc_radius, _start_ang, _end_ang)) =
|
||||
let Some((ref_frame, _arc_radius, start_ang, _end_ang)) =
|
||||
curve.is_arc(k, 11, ANALYTIC_TOL)?
|
||||
else {
|
||||
return Err(sm_err());
|
||||
};
|
||||
ref_frame
|
||||
(ref_frame, start_ang)
|
||||
};
|
||||
|
||||
// select new fillet surface storage spot
|
||||
@@ -1324,13 +1323,14 @@ impl FilletSolver {
|
||||
let mut p1 = eval_offset(k, s, 1, start_uv1)?; // sP1 = rail2 point
|
||||
s.reload_surface_offsets()?;
|
||||
|
||||
// Not upstream (WR-422): when the section's centre lies on the arc's axis the rail
|
||||
// points carry no angle, so turn them into the half-planes through the edge's ends.
|
||||
// `fixes.fillet_torus_center_on_axis` (WR-422): when the section's centre lies on
|
||||
// the arc's axis the rail points carry no angle, so turn them into the half-planes
|
||||
// through the edge's ends.
|
||||
let ivl = edge::interval(b, e)?;
|
||||
let unit_axis = ref_frame.z_axis().unitize()?;
|
||||
let axis_pnt = ref_frame.origin();
|
||||
let center_tol = ops.solver().approx_tol_3d;
|
||||
let center_on_axis = {
|
||||
let center_on_axis = k.fixes.fillet_torus_center_on_axis && {
|
||||
let off = arc_center - axis_pnt;
|
||||
(off - unit_axis * off.dot(unit_axis)).length() < center_tol
|
||||
};
|
||||
@@ -1344,14 +1344,16 @@ impl FilletSolver {
|
||||
None
|
||||
};
|
||||
|
||||
// Not upstream (WR-592): a rail point within the tolerance of the axis turns round
|
||||
// it in a circle smaller than the tolerance, a point: it is the axis's point, so the
|
||||
// surface is exactly singular there.
|
||||
for rail_pnt in [&mut p0, &mut p1] {
|
||||
let off = *rail_pnt - axis_pnt;
|
||||
let on_axis = axis_pnt + unit_axis * off.dot(unit_axis);
|
||||
if on_axis.distance(*rail_pnt) < center_tol {
|
||||
*rail_pnt = on_axis;
|
||||
// `fixes.fillet_torus_rail_on_axis` (WR-592): a rail point within the tolerance of
|
||||
// the axis turns round it in a circle smaller than the tolerance, a point: it is
|
||||
// the axis's point, so the surface is exactly singular there.
|
||||
if k.fixes.fillet_torus_rail_on_axis {
|
||||
for rail_pnt in [&mut p0, &mut p1] {
|
||||
let off = *rail_pnt - axis_pnt;
|
||||
let on_axis = axis_pnt + unit_axis * off.dot(unit_axis);
|
||||
if on_axis.distance(*rail_pnt) < center_tol {
|
||||
*rail_pnt = on_axis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1436,11 +1438,16 @@ impl FilletSolver {
|
||||
span_ang += 2.0 * PI;
|
||||
}
|
||||
|
||||
let end_ang = rad_to_deg(span_ang);
|
||||
// Not upstream (WR-568, the fork's tangency-labs/smlib@985faae): the span is the
|
||||
// turn from the start section to the end one; the start angle `IsArc` gives is
|
||||
// measured from the arc's own frame (an `SmCircle`'s analytic domain, which need
|
||||
// not start at 0), not from the start section, so it is no part of that turn
|
||||
let mut angle_span = rad_to_deg(span_ang);
|
||||
let mut angle_span = if k.fixes.fillet_torus_span_from_start_section {
|
||||
end_ang
|
||||
} else {
|
||||
end_ang - start_ang
|
||||
};
|
||||
|
||||
// Determine if we need to extend the span even when 'm_bExtendBefore' is FALSE
|
||||
if !curve.is_closed(k, &ivl, 0.0)? && !s.extend_after {
|
||||
|
||||
+37
-28
@@ -2101,8 +2101,9 @@ fn c3_make_intersector<'s>(
|
||||
|
||||
/// :2054-2090: the trace locals (tolerances, `sDir = sPVSt[1]`); a TANGENT last point
|
||||
/// reverses `st.tsect_pnts`, sets `sDir = -sPVEn[1]` and `bReverseTracing`; the local
|
||||
/// extension flags from the solver's. Not upstream (WR-603): a TANGENT first point starts
|
||||
/// the trace toward the next point.
|
||||
/// extension flags from the solver's. With
|
||||
/// `Kernel::fixes.fillet_trace_from_tangent_point_toward_next` (WR-603) a TANGENT first
|
||||
/// point starts the trace toward the next point.
|
||||
fn c3_trace_start(
|
||||
ops: &mut dyn FilletSolverOps,
|
||||
cx: &mut Cx<'_>,
|
||||
@@ -2128,20 +2129,22 @@ fn c3_trace_start(
|
||||
}
|
||||
}
|
||||
|
||||
// Not upstream (WR-603): a trace whose first through point is a tangent point starts
|
||||
// toward the next through point (the chord between their points on the first offset
|
||||
// surface): the offsets touch there and the edge's end tangent can be the line along
|
||||
// which they touch to second order.
|
||||
if let (Some(&first), Some(&next)) = (st.tsect_pnts.first(), st.tsect_pnts.get(1)) {
|
||||
let first = st.store.get(cx.fillets, first)?;
|
||||
if first.point_type == IntersectionPointType::TangentPoint {
|
||||
let uv_from = first.uv_pos(SrfIndex::First);
|
||||
let uv_to = st.store.get(cx.fillets, next)?.uv_pos(SrfIndex::First);
|
||||
let from = offset_evaluate_point(cx.k, ops, 0, uv_from)?;
|
||||
let to = offset_evaluate_point(cx.k, ops, 0, uv_to)?;
|
||||
let chord = to - from;
|
||||
if chord.length() > st.trace.approx_tol_3d {
|
||||
st.trace.dir = chord;
|
||||
// `fixes.fillet_trace_from_tangent_point_toward_next` (WR-603): a trace whose first
|
||||
// through point is a tangent point starts toward the next through point (the chord
|
||||
// between their points on the first offset surface): the offsets touch there and the
|
||||
// edge's end tangent can be the line along which they touch to second order.
|
||||
if cx.k.fixes.fillet_trace_from_tangent_point_toward_next && st.tsect_pnts.len() > 1 {
|
||||
if let (Some(&first), Some(&next)) = (st.tsect_pnts.first(), st.tsect_pnts.get(1)) {
|
||||
let first = st.store.get(cx.fillets, first)?;
|
||||
if first.point_type == IntersectionPointType::TangentPoint {
|
||||
let uv_from = first.uv_pos(SrfIndex::First);
|
||||
let uv_to = st.store.get(cx.fillets, next)?.uv_pos(SrfIndex::First);
|
||||
let from = offset_evaluate_point(cx.k, ops, 0, uv_from)?;
|
||||
let to = offset_evaluate_point(cx.k, ops, 0, uv_to)?;
|
||||
let chord = to - from;
|
||||
if chord.length() > st.trace.approx_tol_3d {
|
||||
st.trace.dir = chord;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2461,12 +2464,14 @@ mod tests_c3 {
|
||||
}
|
||||
|
||||
/// WR-603: two points, the first a tangent point, at uv `(0.2, 0.3)` and
|
||||
/// `(0.6, 0.7)` of the first surface; the trace start direction, and the chord
|
||||
/// between the points on the first offset surface.
|
||||
fn traced_from_tangent_first() -> (Vec3, Vec3) {
|
||||
/// `(0.6, 0.7)` of the first surface; the trace start direction with and without
|
||||
/// `fixes.fillet_trace_from_tangent_point_toward_next`, and the chord between the
|
||||
/// points on the first offset surface.
|
||||
fn traced_from_tangent_first(fix: bool) -> (Vec3, Vec3) {
|
||||
use crate::intersect::surface_intersector::p1::SrfIndex;
|
||||
|
||||
let k = Kernel::default();
|
||||
let mut k = Kernel::default();
|
||||
k.fixes.fillet_trace_from_tangent_point_toward_next = fix;
|
||||
let (a, b) = planes();
|
||||
let mut fs = solver(&k, &a, &b);
|
||||
let mut st = state(line(), [a, b]);
|
||||
@@ -2488,12 +2493,14 @@ mod tests_c3 {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_from_a_tangent_first_point_starts_toward_the_next() {
|
||||
let (dir, chord) = traced_from_tangent_first();
|
||||
fn trace_from_a_tangent_first_point_starts_toward_the_next_with_the_fix() {
|
||||
let (dir, chord) = traced_from_tangent_first(true);
|
||||
assert_eq!(dir, chord);
|
||||
assert!(chord.length() > 0.1, "{chord:?}");
|
||||
// not the filleted edge's start tangent, which upstream starts along
|
||||
assert_ne!(dir, state(line(), planes().into()).pv_start[1]);
|
||||
// upstream: the filleted edge's start tangent
|
||||
let (dir, chord) = traced_from_tangent_first(false);
|
||||
assert_eq!(dir, state(line(), planes().into()).pv_start[1]);
|
||||
assert_ne!(dir, chord);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3817,10 +3824,12 @@ fn c6_compute_point_values(
|
||||
None
|
||||
};
|
||||
|
||||
// :3664 central difference, or one-sided at a domain end. Not upstream (WR-595): a
|
||||
// tangent point, where the intersection curve ends or branches, takes the forward
|
||||
// step alone when it found one (`bFoundForw && bFoundBack && !bTangentPoint`).
|
||||
let tangent_point = point.point_type == IntersectionPointType::TangentPoint;
|
||||
// :3664 central difference, or one-sided at a domain end. With
|
||||
// `fixes.fillet_tangent_point_one_sided_step` (WR-595) a tangent point, where the
|
||||
// intersection curve ends or branches, takes the forward step alone when it found
|
||||
// one (`bFoundForw && bFoundBack && !bTangentPoint`).
|
||||
let tangent_point = k.fixes.fillet_tangent_point_one_sided_step
|
||||
&& point.point_type == IntersectionPointType::TangentPoint;
|
||||
let (step_pt_forw, step_pt_back) = match (forw_pts, back_pts) {
|
||||
(Some((f0, f1)), Some((b0, b1))) if !tangent_point => {
|
||||
let fw = (f0 + f1) / 2.0;
|
||||
|
||||
@@ -341,7 +341,9 @@ impl FilletSolver {
|
||||
fillets.with_rail_classification(rail, |pseudo, cc| {
|
||||
context.classify(k, target, pseudo, face, cc)
|
||||
})?;
|
||||
join_intervals_on_face(fillets, rail)?;
|
||||
if k.fixes.fillet_rail_rim_on_face {
|
||||
join_intervals_on_face(fillets, rail)?;
|
||||
}
|
||||
finish_classification(
|
||||
solver,
|
||||
k,
|
||||
|
||||
@@ -36,10 +36,6 @@
|
||||
//! `boolean_union_refuses_solid_leak` (own design, WR-619), a union whose step 6 would
|
||||
//! delete a face between the infinite region and a solid region is refused with `SM_ERR`
|
||||
//! (the face in the error) instead of returning a body whose solid merged into the outside.
|
||||
//! Own design, not upstream: in step 6 a region that a deleted face joins to another, neither
|
||||
//! with merge data (no face of the other body was copied into them, as when a union fills a
|
||||
//! void exactly), takes the class a deleted face has on both sides ([`deleted_face_sides`]);
|
||||
//! upstream leaves it the flag it had, so the filled void stayed void.
|
||||
|
||||
use super::p1::{self, MergeOptions};
|
||||
use crate::error::boolean::BooleanFailure;
|
||||
@@ -1220,58 +1216,6 @@ fn result_solid(operation: BooleanOperation, data: &MergeRegionData, was_solid:
|
||||
}
|
||||
}
|
||||
|
||||
/// What a regularized operation makes both sides of a face it deletes (§4.4 of
|
||||
/// `docs/booleans.md`): solid for a union, void for an intersection or a difference. Each
|
||||
/// deletes a face exactly where the result is the same on both sides: a union a face inside
|
||||
/// the other body or a coincident pair bounding material on opposite sides, an intersection
|
||||
/// a face outside it or such a pair, a difference a face inside it or a pair bounding
|
||||
/// material on the same side. `None` for the operations whose deletions are not all one class.
|
||||
fn deleted_face_sides(operation: BooleanOperation) -> Option<bool> {
|
||||
match operation {
|
||||
BooleanOperation::Union => Some(true),
|
||||
BooleanOperation::Intersection | BooleanOperation::Difference => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The regions of `primary` on either side of a face in `delete` that joins two of them
|
||||
/// with no merge data on either, the infinite region aside. A region gets merge data when a
|
||||
/// face of the other body is copied into it ([`remember_split`]); one whose boundary the
|
||||
/// other body's faces all lie on gets none, as the void a union fills exactly, so the
|
||||
/// deletion joins it to its neighbour with nothing to give the joined region its flag.
|
||||
fn joined_without_data(
|
||||
primary: &Brep,
|
||||
intersector: &impl ManifoldIntersector,
|
||||
attributes: &AttributeStore,
|
||||
delete: &[FaceId],
|
||||
) -> Result<Vec<RegionId>> {
|
||||
let infinite = primary.infinite_region();
|
||||
let recorded = |r: RegionId| {
|
||||
merge_data(
|
||||
attributes,
|
||||
intersector.object_key(false, MergeEntity::Region(r)),
|
||||
)
|
||||
.is_some()
|
||||
};
|
||||
let mut joined = Vec::new();
|
||||
for &f in delete {
|
||||
let Some(fu) = face::faceuses(primary, f)?.0 else {
|
||||
continue;
|
||||
};
|
||||
let (r1, r2) = region_pair(primary, fu)?;
|
||||
if r1 == r2 || infinite == Some(r1) || infinite == Some(r2) || recorded(r1) || recorded(r2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for r in [r1, r2] {
|
||||
if !joined.contains(&r) {
|
||||
joined.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(joined)
|
||||
}
|
||||
|
||||
/// Every region's enclosing shell first, the region's outer shell that
|
||||
/// `SmBrep::AssertValid` test 12 takes it to be (the fork's fix, tangency-labs/smlib
|
||||
/// `abastran/void-shell-check`), only with the opt-in
|
||||
@@ -1541,7 +1485,6 @@ fn editing_stages(
|
||||
return Err(Failure::new(OP, BooleanFailure::SolidLeaks { face: f }).into_error());
|
||||
}
|
||||
}
|
||||
let joined = joined_without_data(primary, intersector, attributes, &faces.delete)?;
|
||||
intersector.delete_faces(
|
||||
kernels,
|
||||
primary,
|
||||
@@ -1556,8 +1499,6 @@ fn editing_stages(
|
||||
) {
|
||||
let solid = result_solid(operation, data, void(primary, r)? == 0);
|
||||
primary.region_mut(r)?.set_is_void(i32::from(!solid));
|
||||
} else if let (true, Some(solid)) = (joined.contains(&r), deleted_face_sides(operation)) {
|
||||
primary.region_mut(r)?.set_is_void(i32::from(!solid));
|
||||
}
|
||||
}
|
||||
if !infinite_current || void(primary, required(primary.infinite_region())?)? == 0 {
|
||||
|
||||
-204
@@ -40,11 +40,6 @@ use super::{
|
||||
use crate::topology::{EdgeId, FaceId};
|
||||
|
||||
/// Why an offset, shell, thicken or local operation is refused.
|
||||
///
|
||||
/// A caller outside the crate matches it with a wildcard: a reason it does not name yet still
|
||||
/// has its class ([`OffsetFailure::kind`]) and says whether it is a phrase
|
||||
/// ([`OffsetFailure::is_phrase`]) or a defect ([`OffsetFailure::is_defect`]), which every reason
|
||||
/// must answer here.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum OffsetFailure {
|
||||
@@ -294,62 +289,6 @@ impl OffsetFailure {
|
||||
)
|
||||
}
|
||||
|
||||
/// True for a phrase cause ([`OffsetFailure::Failed`], [`OffsetFailure::Missing`],
|
||||
/// [`OffsetFailure::InvalidValue`], [`OffsetFailure::Inconsistent`]): a routine's failure
|
||||
/// not yet given a cause of its own, told apart only by its words. Its class is its status's
|
||||
/// kind; each moves to a cause of its own, as the blend's phrase causes do
|
||||
/// ([`crate::error::blend::BlendFailure`]).
|
||||
#[must_use]
|
||||
pub const fn is_phrase(&self) -> bool {
|
||||
match self {
|
||||
Self::Folds { .. }
|
||||
| Self::NotCertified { .. }
|
||||
| Self::Collapses { .. }
|
||||
| Self::WallsTouch { .. }
|
||||
| Self::LoopsCross { .. }
|
||||
| Self::EdgeVanishes { .. }
|
||||
| Self::DistanceUnresolved { .. }
|
||||
| Self::ToleranceUnresolved { .. }
|
||||
| Self::SeamOpened { .. }
|
||||
| Self::ExactCurveUncut
|
||||
| Self::FacesApart { .. }
|
||||
| Self::ReachUncertified { .. }
|
||||
| Self::LoopGapUncertified { .. }
|
||||
| Self::Unsound { .. } => false,
|
||||
Self::Failed(_) | Self::Missing(_) | Self::InvalidValue(_) | Self::Inconsistent(_) => {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True for a failure that is the kernel's fault, not a fact of the input: a consistency
|
||||
/// check of a routine's own data that did not hold ([`OffsetFailure::Inconsistent`]).
|
||||
/// Report it. Said per cause, as its class ([`ErrorKind::Failed`]) does not say it, until
|
||||
/// offset failures move onto a class of their own.
|
||||
#[must_use]
|
||||
pub const fn is_defect(&self) -> bool {
|
||||
match self {
|
||||
Self::Inconsistent(_) => true,
|
||||
Self::Folds { .. }
|
||||
| Self::NotCertified { .. }
|
||||
| Self::Collapses { .. }
|
||||
| Self::WallsTouch { .. }
|
||||
| Self::LoopsCross { .. }
|
||||
| Self::EdgeVanishes { .. }
|
||||
| Self::DistanceUnresolved { .. }
|
||||
| Self::ToleranceUnresolved { .. }
|
||||
| Self::SeamOpened { .. }
|
||||
| Self::ExactCurveUncut
|
||||
| Self::FacesApart { .. }
|
||||
| Self::ReachUncertified { .. }
|
||||
| Self::LoopGapUncertified { .. }
|
||||
| Self::Unsound { .. }
|
||||
| Self::Failed(_)
|
||||
| Self::Missing(_)
|
||||
| Self::InvalidValue(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The status the C++ reports for the refusal (difftest's comparison detail).
|
||||
pub(crate) const fn comparison_status(&self) -> i32 {
|
||||
match self {
|
||||
@@ -720,149 +659,6 @@ mod tests {
|
||||
assert_eq!(SolidError::sm_err("offset::op").offset_failure(), None);
|
||||
}
|
||||
|
||||
/// Every offset failure's class, and whether it is a phrase (a routine's failure not yet
|
||||
/// given a cause of its own) or a defect of the kernel, pinned. A new reason must answer
|
||||
/// all three (`kind`, `is_phrase` and `is_defect` match exhaustively) and adds its arm to
|
||||
/// `row` and its row here, so a caller outside the crate, which has to match with a
|
||||
/// wildcard, still gets its class and never only its text (#15035).
|
||||
#[test]
|
||||
fn every_offset_failure_keeps_its_class() {
|
||||
use OffsetFailure as O;
|
||||
let face = FaceId::from_index(1);
|
||||
// (the failure, its class, a phrase, a defect)
|
||||
let pinned = [
|
||||
(
|
||||
O::Folds {
|
||||
face,
|
||||
distance: 2.0,
|
||||
largest: 1.0,
|
||||
},
|
||||
ErrorKind::OffsetFolds,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
O::NotCertified {
|
||||
face,
|
||||
distance: 2.0,
|
||||
},
|
||||
ErrorKind::OffsetFolds,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
O::Collapses {
|
||||
face,
|
||||
distance: -5.0,
|
||||
radius: 5.0,
|
||||
},
|
||||
ErrorKind::OffsetCollapses,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
O::WallsTouch {
|
||||
face: None,
|
||||
contact: WallContact::Touch,
|
||||
},
|
||||
ErrorKind::OffsetWallsTouch,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
O::LoopsCross {
|
||||
face: None,
|
||||
crossing: LoopCrossing::SharesEdge,
|
||||
},
|
||||
ErrorKind::OffsetLoopsCross,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
O::EdgeVanishes {
|
||||
edge: None,
|
||||
face: None,
|
||||
},
|
||||
ErrorKind::OffsetEdgeVanishes,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
O::DistanceUnresolved {
|
||||
distance: 1e-9,
|
||||
resolution: 1e-6,
|
||||
},
|
||||
ErrorKind::InvalidInput,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
O::ToleranceUnresolved { tolerance: 1e-13 },
|
||||
ErrorKind::InvalidInput,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(O::SeamOpened { face }, ErrorKind::Unsupported, false, false),
|
||||
(O::ExactCurveUncut, ErrorKind::Unsupported, false, false),
|
||||
(O::FacesApart { face }, ErrorKind::Unsupported, false, false),
|
||||
(
|
||||
O::ReachUncertified { face },
|
||||
ErrorKind::Unsupported,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
O::LoopGapUncertified { face: None },
|
||||
ErrorKind::Unsupported,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
O::Unsound {
|
||||
what: Unsoundness::NotManifoldSolid,
|
||||
},
|
||||
ErrorKind::Unsupported,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(O::Failed("x"), ErrorKind::Failed, true, false),
|
||||
(O::Missing("x"), ErrorKind::Missing, true, false),
|
||||
(O::InvalidValue("x"), ErrorKind::InvalidInput, true, false),
|
||||
// a consistency check of the kernel's own data that did not hold: its fault
|
||||
(O::Inconsistent("x"), ErrorKind::Failed, true, true),
|
||||
];
|
||||
let row = |o: &O| -> usize {
|
||||
match o {
|
||||
O::Folds { .. } => 0,
|
||||
O::NotCertified { .. } => 1,
|
||||
O::Collapses { .. } => 2,
|
||||
O::WallsTouch { .. } => 3,
|
||||
O::LoopsCross { .. } => 4,
|
||||
O::EdgeVanishes { .. } => 5,
|
||||
O::DistanceUnresolved { .. } => 6,
|
||||
O::ToleranceUnresolved { .. } => 7,
|
||||
O::SeamOpened { .. } => 8,
|
||||
O::ExactCurveUncut => 9,
|
||||
O::FacesApart { .. } => 10,
|
||||
O::ReachUncertified { .. } => 11,
|
||||
O::LoopGapUncertified { .. } => 12,
|
||||
O::Unsound { .. } => 13,
|
||||
O::Failed(_) => 14,
|
||||
O::Missing(_) => 15,
|
||||
O::InvalidValue(_) => 16,
|
||||
O::Inconsistent(_) => 17,
|
||||
}
|
||||
};
|
||||
for (i, (o, class, phrase, defect)) in pinned.iter().enumerate() {
|
||||
assert_eq!(row(o), i, "{o:?} is listed once, in order");
|
||||
assert_eq!(o.kind(), *class, "{o:?}");
|
||||
assert_eq!(o.is_phrase(), *phrase, "{o:?}");
|
||||
assert_eq!(o.is_defect(), *defect, "{o:?}");
|
||||
// a phrase is what replaced a bare status, and only a phrase
|
||||
assert_eq!(o.replaces_upstream_status(), *phrase, "{o:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The offset routines describe their failures: their library code builds no bare
|
||||
/// `Upstream` status, except the files listed (with the bare sites each may still
|
||||
/// hold) whose conversion waits on other open work.
|
||||
|
||||
+18
-14
@@ -2082,21 +2082,24 @@ pub trait GlobalSolve<'t, O: SolutionObject + 't> {
|
||||
// for every current solution
|
||||
let num_sols = self.solver().solutions.len();
|
||||
for ii in 0..num_sols {
|
||||
// read in place: a copy per stored solution per insertion cost a memcpy each
|
||||
let test = self.solver().solutions.at(ii)?;
|
||||
let test = self.solver().solutions.get_at(ii)?;
|
||||
|
||||
// don't add duplicate solutions (SolValues within ZeroSqrt and ParamValues within ScaledZero)
|
||||
let (pair, preferred) = self.identical_solutions(solution, test)?;
|
||||
let (pair, preferred) = self.identical_solutions(solution, &test)?;
|
||||
if pair == SolutionPairType::Identical {
|
||||
// save the preferred solution if possible, else better solution - improve tolerances
|
||||
// (keeping the stored one, `Second`, is writing it back unchanged: nothing to do)
|
||||
let keep = match preferred {
|
||||
Some(PreferredSolution::First) => true,
|
||||
Some(PreferredSolution::Second) => false,
|
||||
None => solution.start.solution_value.abs() < test.start.solution_value.abs(),
|
||||
Some(PreferredSolution::First) => Some(solution),
|
||||
Some(PreferredSolution::Second) => Some(&test),
|
||||
None if solution.start.solution_value.abs()
|
||||
< test.start.solution_value.abs() =>
|
||||
{
|
||||
Some(solution)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if keep {
|
||||
self.solver_mut().solutions.set_at(ii, solution)?;
|
||||
if let Some(keep) = keep {
|
||||
self.solver_mut().solutions.set_at(ii, keep)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -2250,11 +2253,10 @@ pub trait GlobalSolve<'t, O: SolutionObject + 't> {
|
||||
// points contained in intervals get absorbed.
|
||||
// disjoint intervals remain disjoint.
|
||||
// point solutions remain distinct.
|
||||
// in place: combine_min_max changes the pair only on its split path,
|
||||
// after every fallible step, so an error leaves both as they were, as
|
||||
// the copies did
|
||||
let (min, max) = s.solutions.pair_mut(k, j)?;
|
||||
let combined = combine_min_max(op, min, max, combine_contiguous_ranges)?;
|
||||
let mut min = s.solutions.get_at(k)?;
|
||||
let mut max = s.solutions.get_at(j)?;
|
||||
let combined =
|
||||
combine_min_max(op, &mut min, &mut max, combine_contiguous_ranges)?;
|
||||
if let Some(combined) = combined {
|
||||
s.solutions.set_at(k, &combined)?;
|
||||
s.solutions.remove_at(j, 1);
|
||||
@@ -2262,6 +2264,8 @@ pub trait GlobalSolve<'t, O: SolutionObject + 't> {
|
||||
break;
|
||||
}
|
||||
// (the split of a range around a tight point is made in place)
|
||||
s.solutions.set_at(k, &min)?;
|
||||
s.solutions.set_at(j, &max)?;
|
||||
j += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1538,37 +1538,6 @@ impl<O: SolutionObject> SolutionArray<O> {
|
||||
.ok_or_else(|| bad_index("geom::solution_array::SolutionArray::get_at"))
|
||||
}
|
||||
|
||||
/// Solution `index`, borrowed (`GetAt` without the copy).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`SolidError::InvalidInput`] for an index past the size.
|
||||
pub fn at(&self, index: usize) -> Result<&Solution<O>> {
|
||||
self.data
|
||||
.get(index)
|
||||
.ok_or_else(|| bad_index("geom::solution_array::SolutionArray::at"))
|
||||
}
|
||||
|
||||
/// Solutions `i` and `j` (`i < j`), both borrowed mutably.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`SolidError::InvalidInput`] unless `i < j` and `j` is within the size.
|
||||
pub fn pair_mut(&mut self, i: usize, j: usize) -> Result<(&mut Solution<O>, &mut Solution<O>)> {
|
||||
const OP: &str = "geom::solution_array::SolutionArray::pair_mut";
|
||||
if i >= j {
|
||||
return Err(bad_index(OP));
|
||||
}
|
||||
let (lo, hi) = self
|
||||
.data
|
||||
.split_at_mut_checked(j)
|
||||
.ok_or_else(|| bad_index(OP))?;
|
||||
match (lo.get_mut(i), hi.first_mut()) {
|
||||
(Some(a), Some(b)) => Ok((a, b)),
|
||||
_ => Err(bad_index(OP)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces solution `index` (`SetAt`).
|
||||
///
|
||||
/// # Errors
|
||||
@@ -2263,28 +2232,4 @@ mod tests {
|
||||
assert_eq!(r.solution_type, SolutionType::Unknown);
|
||||
assert_eq!(r.start.parameters[0], 1.0);
|
||||
}
|
||||
|
||||
/// `at` borrows what `get_at` copies; `pair_mut` hands out two distinct solutions and refuses
|
||||
/// a pair out of order, the same index twice or an index past the size.
|
||||
#[test]
|
||||
fn borrowed_access() {
|
||||
let mut a: SolutionArray<Obj> = SolutionArray::new();
|
||||
a.add(&single(1.0, &[1.0]));
|
||||
a.add(&single(2.0, &[2.0]));
|
||||
a.add(&single(3.0, &[3.0]));
|
||||
assert_eq!(*a.at(1).unwrap(), a.get_at(1).unwrap());
|
||||
assert!(matches!(a.at(3), Err(SolidError::InvalidInput { .. })));
|
||||
let (x, y) = a.pair_mut(0, 2).unwrap();
|
||||
assert_eq!((x.start.solution_value, y.start.solution_value), (1.0, 3.0));
|
||||
x.start.solution_value = 10.0;
|
||||
y.start.solution_value = 30.0;
|
||||
assert_eq!(a.get_at(0).unwrap().start.solution_value, 10.0);
|
||||
assert_eq!(a.get_at(2).unwrap().start.solution_value, 30.0);
|
||||
for (i, j) in [(1, 1), (2, 1), (0, 3)] {
|
||||
assert!(matches!(
|
||||
a.pair_mut(i, j),
|
||||
Err(SolidError::InvalidInput { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -459,6 +459,8 @@ pub struct UpstreamFixes {
|
||||
pub curve_loop_normal_from_area: bool,
|
||||
/// `CalcRailIntEdgeuse`'s retry rail search holds a variable round's radius only where its first search does (at a corner calculated as constant radius), so the rail misses the arc it would meet only at that radius and the corner cuts the round off against the body; needs `fillet_nx1_end_rib_global_merge` there (WR-300, #1023). `RailRailIntersect` holds the radii at their end values only there too (its callers `CalcRailIntRail` and `CalcRailRailInterpolation`'s cubic rail interpolant): held at any other corner, two variable rounds meeting at a box's corner met where the laws are smaller and the trace from there failed with 1001 (the fork's tangency-labs/smlib@465b62b, the rule of 1d06159).
|
||||
pub fillet_retry_radius_as_first: bool,
|
||||
/// `TestRollOver` joins a rail's neighbouring intervals on its face or the face's boundary before the rollover, so a rail along its face's rim does not fail "Unable to Process Rollover" (WR-301, #485).
|
||||
pub fillet_rail_rim_on_face: bool,
|
||||
/// The fillet executive refuses a blend whose rails leave the faces they roll on (`RailsFitTheirFaces`, no room) (WR-302, #1024/#841).
|
||||
pub fillet_refuse_rails_leaving_faces: bool,
|
||||
/// `create_fillet_brep` refuses blends that take the whole of a face they roll on (`FacesOutlastTheirBlends`, no room) (WR-320, #879).
|
||||
@@ -475,6 +477,8 @@ pub struct UpstreamFixes {
|
||||
pub fillet_corner_ball_center_from_axes: bool,
|
||||
/// `SmFilletCorner::MakeVariableRadiusFillet` refuses to turn a four-sided corner into a variable-radius fillet when the new fillet's rail along the opposite fillet leaves that fillet's surface (more than ten times the corner's tolerance from its natural domain): the rail replaces the opposite edge, which bounds the opposite fillet's face, so that face would end off its own edge. The corner is then the patch on its exact edges. At a drafted pocket's top corners the rail ran 0.42 off the corner round at R3, 10 degrees (WR-645, #968).
|
||||
pub fillet_corner_variable_radius_rail_on_opposite_fillet: bool,
|
||||
/// `CalcTorusFilletGeom` takes the round's span as the turn from its start section to its end one, without taking `IsArc`'s start angle off it: an `SmCircle`'s start angle is measured from the circle's own frame (a boolean's circle framed on a cylinder's seam starts at 60 or 115 degrees), so the torus came out short by it and the end rib found no fillet surface at the far end (1001 at an Nx1 corner) (WR-568; the fork's tangency-labs/smlib@985faae).
|
||||
pub fillet_torus_span_from_start_section: bool,
|
||||
/// `CalcTorusFilletGeom` revolves a linear section into a cone (WR-421, #420).
|
||||
pub fillet_chamfer_cone_on_arc: bool,
|
||||
/// With `fillet_chamfer_cone_on_arc`, `CalcTorusFilletGeom` stores a linear section's revolution as the `SmCone` it is: the revolution's NURBS form is copied (`SmBSplineSurface::Copy`) and passed through `CopyAndAddAnalytics`, and when that gives a cone it is the fillet surface and the rails are made on it (as a straight edge's round is stored as its cylinder); anything else stays the surface of revolution. Without it a chamfer round a hole's rim is a surface of revolution, which reads back as a rational B-spline, not the cone it is (WR-572, #1503; the fork's tangency-labs/smlib@b5743ec).
|
||||
@@ -483,7 +487,11 @@ pub struct UpstreamFixes {
|
||||
pub corner_blend_coons_patch_tangent_to_sides: bool,
|
||||
/// `SmFilletCorner::CreateCornerPatch` marks the patch sides that lie on a face of the body (a fillet edge with an original face) and `sm_CreateCoonsSrf` gives a corner between such a side and a blend's side the blend side's twist instead of the mean of the two: a bicubic Coons patch meets a side's cross-boundary derivative only where the corner twists are the other sides' own, so the mean tilted the patch off the face's tangent plane beside that side and it crossed the face (0.037 out of a bar's side face where the bar's top-edge round meets the concave round at a cylinder it runs tangent into, r 2). Corners between two face sides or two blend sides keep the mean (WR-613, #1836; the fork's tangency-labs/smlib@7fd9fb0 (commit not in fork history at 26cc451), from the issue's diff).
|
||||
pub corner_patch_face_side_twist: bool,
|
||||
/// The fillet solver decides whether a constant round is exact by one classification of its spine (the curve the ball's centre runs on) from its faces' kinds and placement, read as the natural quadrics they lie on (plane, sphere, circular cylinder or cone, an offset of one): a line (two planes; a plane or cylinder parallel to a cylinder's axis) is built as the cylinder, a circle about a common axis (coaxial quadrics, a plane square to the axis, a sphere's centre on it) as the section turned about that axis, a point (two cylinders as wide as the ball, axes meeting) as the ball's piece; every other round is traced. This replaces upstream's try-and-fall-through chain (`CanMakeAnalytic`, planes and cones only, then cylinder, torus and with `fillet_corner_ball_patch` the sphere, each deciding from the edge's shape) at `CalcFilletGeom` and at `ReCalcFilletGeom` (where a line round is rebuilt as its cylinder, not the cone), and a builder's failure is then an error, not a trace. So a round on a sphere is exact, and a circular round's frame comes from the faces' axis, not the edge's `IsArc` (WR-669; the fork's tangency-labs/smlib@b5f2867 and 7ac037e). The torus and ball builders' own flags still apply inside them: the fork's behaviour is this with `fillet_chamfer_cone_on_arc`, `fillet_chamfer_cone_stored_as_cone` and `fillet_corner_ball_center_from_axes` on (a chamfer round a circle is traced without `fillet_chamfer_cone_on_arc`). A conic spine (a plane with a cylinder or cone at any other angle; two cylinders or cones touching a common ball the same way round) is built as the exact rational surface of the ball's arcs, checked against the ball before it is kept (the fork's tangency-labs/smlib@0061c41 and 1b5f1e7); the fork's nodal-quartic rounds are classified but still traced here. A line round's cylinder is built on its exact spine, the line its offsets meet in, its rails on the faces' feet of it, and keeps that frame instead of the stations' centres and a frame refitted from its net (#2405, solidcore-native).
|
||||
/// A torus blend whose section centre is on the arc's axis turns rail points into the edge ends' half-planes (WR-422, #417).
|
||||
pub fillet_torus_center_on_axis: bool,
|
||||
/// `CalcTorusFilletGeom` moves a section's rail point that lies within the solver's tolerance (`GetThisApproxTol3d`) of the edge arc's axis onto the axis: it turns about the axis in a circle smaller than the tolerance, so the rail collapses to the axis's point and the surface of revolution is exactly singular there, where upstream left the point off the axis by the model's residues (1.2e-11 after a sketch's 1e-10) and the surface nearly singular, which `smlib_brep_check` refused (`SmSurface` #5, Bad Surf Pole Approximation; a round along an earlier round's end arc of the ball's own radius) (WR-592; the fork's tangency-labs/smlib@994f129). Whether the result then passes the check depends on the input's residues: on the bar `tests/wr592_torus_rail_on_axis.rs` rebuilds, the pinned SMLib with this hunk alone and the port both leave `SmSurfOfRevolution` #7 failing by roundoff, where the fork's own input passes (`plan/findings/blend.fillet_solver_p2--wr592-revolution-polar-start-angle.md`).
|
||||
pub fillet_torus_rail_on_axis: bool,
|
||||
/// The fillet solver decides whether a constant round is exact by one classification of its spine (the curve the ball's centre runs on) from its faces' kinds and placement, read as the natural quadrics they lie on (plane, sphere, circular cylinder or cone, an offset of one): a line (two planes; a plane or cylinder parallel to a cylinder's axis) is built as the cylinder, a circle about a common axis (coaxial quadrics, a plane square to the axis, a sphere's centre on it) as the section turned about that axis, a point (two cylinders as wide as the ball, axes meeting) as the ball's piece; every other round is traced. This replaces upstream's try-and-fall-through chain (`CanMakeAnalytic`, planes and cones only, then cylinder, torus and with `fillet_corner_ball_patch` the sphere, each deciding from the edge's shape) at `CalcFilletGeom` and at `ReCalcFilletGeom` (where a line round is rebuilt as its cylinder, not the cone), and a builder's failure is then an error, not a trace. So a round on a sphere is exact, and a circular round's frame comes from the faces' axis, not the edge's `IsArc` (WR-669; the fork's tangency-labs/smlib@b5f2867 and 7ac037e). The torus and ball builders' own flags still apply inside them: the fork's behaviour is this with `fillet_torus_span_from_start_section`, `fillet_chamfer_cone_on_arc`, `fillet_chamfer_cone_stored_as_cone`, `fillet_torus_center_on_axis`, `fillet_torus_rail_on_axis` and `fillet_corner_ball_center_from_axes` on (a chamfer round a circle is traced without `fillet_chamfer_cone_on_arc`). A conic spine (a plane with a cylinder or cone at any other angle; two cylinders or cones touching a common ball the same way round) is built as the exact rational surface of the ball's arcs, checked against the ball before it is kept (the fork's tangency-labs/smlib@0061c41 and 1b5f1e7); the fork's nodal-quartic rounds are classified but still traced here. A line round's cylinder is built on its exact spine, the line its offsets meet in, its rails on the faces' feet of it, and keeps that frame instead of the stations' centres and a frame refitted from its net (#2405, solidcore-native).
|
||||
pub fillet_exact_round_classes: bool,
|
||||
/// `SmFilletExecutive::CreateFilletBrep` stitches the fillet body keeping a fillet's own edge where an edge of corner-patch faces only meets an edge of other fillet faces only (`SmFilletPatchStitchCallback`): an N-sided corner patch is bounded by its surfaces' isoparametric curves, the approximations it was fitted to, and the default choice between the two near-equal distances left one half of a set-back section the exact arc and the other the polynomial approximation, off the round (WR-546, #1377; the fork's tangency-labs/smlib@1072d1b).
|
||||
pub fillet_patch_keeps_fillet_edges: bool,
|
||||
@@ -1057,8 +1065,12 @@ pub struct UpstreamFixes {
|
||||
pub imprint_reuses_coincident_face_edge: bool,
|
||||
/// `SmFilletSolver::ComputePointValues` chooses its finite-difference step for each point (1e-3 at a tangent point, 1e-5 otherwise), not once per process as the function-local static `sdStaticStep`, so a blend does not depend on the blends before it ([`Kernel::fillet_point_step`] is then neither read nor set, and an existing latch is kept as it is) (ND-3, #1173; the fork's tangency-labs/smlib@9e17f64 (was 0a05860)).
|
||||
pub fillet_point_step_per_point: bool,
|
||||
/// `SmFilletSolver::CalcFilletGeom` starts a trace whose first through point (after the reversal for a tangent last point) is a tangent point toward the next through point, the chord between their points on the first offset surface (when longer than the tolerance), not along the filleted edge's end tangent: the offsets touch at a tangent point, and that tangent can be the line along which they touch to second order, so the first step lay on both offsets within tolerance, the trace missed the next point and ran round a cylinder (a bar's top edge rounded into a cylinder tangent to the bar's side, refused as fillet error 8) (WR-603; the fork's tangency-labs/smlib@68fb916).
|
||||
pub fillet_trace_from_tangent_point_toward_next: bool,
|
||||
/// `SmBSplineSurface::CopyAndAddAnalytics` keeps an exact copy where the recognised analytic surface does not give the same points at the same parameters (7 x 7 grid), so a face split by `MakeEdgeInFaceWithVU` keeps trims that still lie on its edges (WR-450, #387; the fork's tangency-labs/smlib@7af1987 (was 276b509)).
|
||||
pub analytic_copy_keeps_parameterization: bool,
|
||||
/// `SmFilletSolver::ComputePointValues` sets a tangent point's curve tangent from the forward finite-difference step alone (along the trace, the previous point's tangent), and from the backward step only when the forward one finds nothing, not from their central difference: the intersection curve ends or branches at a tangent point, and where the offset surfaces touch only to within the model's accuracy (a sketch's 1e-10 residues) the backward step lands on the near-tangent crossing beyond it, 18 degrees off the curve, so every step after the point failed the angle test and the trace crept on in steps down to 8e-10 (repeated rail control points, an internal pole) (WR-595; the fork's tangency-labs/smlib@f8fd83e). Use with `fillet_trace_from_tangent_point_toward_next`, as the fork has both: alone, the forward step from a trace's tangent first point runs along the edge's end tangent, and `Fillet/nurb_cone.smb` edge 8 at r 0.5, which upstream builds, fails.
|
||||
pub fillet_tangent_point_one_sided_step: bool,
|
||||
/// `SmFace::ComputePreciseProperties` scales the volume and moment error bounds by the body size (ten times the root of the estimated area, or the origin's distance when larger), not 1.0.1's tenth of it, so the integration converges at tight accuracies instead of returning `SM_ERR` (WR-472; the SolidCraft fork's tangency-labs/smlib@073d4740).
|
||||
pub precise_properties_body_length_scale: bool,
|
||||
/// `SmVertex::IsTopologicalVertex` ([`crate::topology::vertex::is_topological_vertex`])
|
||||
|
||||
-187
@@ -13,7 +13,6 @@
|
||||
//! | [`Transform::concat`] | `SmTransform::ConcatTransform` / `smgu_4x4Mult`|
|
||||
//! | [`Transform::transform_point`] | `SmTransform::TransformPoint` |
|
||||
//! | [`Transform::transform_vector`] | `SmTransform::TransformVector` |
|
||||
//! | [`Transform::rotation`], [`Transform::uniform_scaling`], [`Transform::reflection`] | (not upstream) |
|
||||
//! | [`EFF_ZERO`] etc. | `SM_EFF_ZERO` etc. |
|
||||
//! | [`is_infinite_param`] | `SM_IS_INFINITE` |
|
||||
//! | [`max2`], [`min2`], [`max3`], [`min3`], [`max4`], [`min4`] | `smos_Max`, `smos_Min`, `smos_3Max`, `smos_3Min`, `smos_4Max`, `smos_4Min` |
|
||||
@@ -410,196 +409,10 @@ impl Transform {
|
||||
}
|
||||
}
|
||||
|
||||
/// The operation name of the refusals of the maps built from their numbers.
|
||||
const OP_MAPS: &str = "math::Transform";
|
||||
|
||||
/// Whether every coordinate of `v` is finite.
|
||||
fn finite3(v: Vec3) -> bool {
|
||||
v.x.is_finite() && v.y.is_finite() && v.z.is_finite()
|
||||
}
|
||||
|
||||
/// Not upstream: the rigid motions and scalings an application asks for by their numbers
|
||||
/// (a turn about a line, a scale about a point, a mirror in a plane), built here so the
|
||||
/// layers above the kernel only pass the numbers on. Each is in the row-vector convention
|
||||
/// of [`Transform`]: rows 0 to 2 the images of x, y and z, row 3 the translation.
|
||||
impl Transform {
|
||||
/// The linear map whose rows are `x`, `y` and `z`, about the point `fixed`, which it
|
||||
/// keeps: `p' = (p - fixed) A + fixed`, its translation `fixed - fixed A`.
|
||||
fn about(fixed: Point3, x: Vec3, y: Vec3, z: Vec3) -> Self {
|
||||
let image = x * fixed.x + y * fixed.y + z * fixed.z;
|
||||
Self::from_axes(x, y, z, fixed - image)
|
||||
}
|
||||
|
||||
/// The turn by `angle` radians, right-handed about `axis`, about the line through
|
||||
/// `point` along `axis` (any length, made unit here): Rodrigues' rotation formula,
|
||||
/// `R = c I + s [k]x + (1 - c) k kᵀ` with `k` the unit axis, `c` and `s` the cosine and
|
||||
/// sine of the angle, transposed for the row-vector convention.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `InvalidInput` for a point, axis or angle not finite, or an axis whose squared
|
||||
/// length is below [`EFF_ZERO_SQ`].
|
||||
pub fn rotation(point: Point3, axis: Vec3, angle: f64) -> crate::error::Result<Self> {
|
||||
use crate::error::SolidError;
|
||||
if !(finite3(point) && finite3(axis) && angle.is_finite()) {
|
||||
return Err(SolidError::invalid_input(
|
||||
OP_MAPS,
|
||||
"a turn's numbers not finite",
|
||||
));
|
||||
}
|
||||
let k = axis
|
||||
.unitize()
|
||||
.map_err(|_| SolidError::invalid_input(OP_MAPS, "a turn's axis of no length"))?;
|
||||
let (s, c) = angle.sin_cos();
|
||||
let v = 1.0 - c;
|
||||
let (x, y, z) = (k.x, k.y, k.z);
|
||||
Ok(Self::about(
|
||||
point,
|
||||
Vec3::new(c + x * x * v, x * y * v + z * s, x * z * v - y * s),
|
||||
Vec3::new(x * y * v - z * s, c + y * y * v, y * z * v + x * s),
|
||||
Vec3::new(x * z * v + y * s, y * z * v - x * s, c + z * z * v),
|
||||
))
|
||||
}
|
||||
|
||||
/// The scale by `factor` in every direction about `centre`, which it keeps. A negative
|
||||
/// factor turns the scale through the centre as well.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `InvalidInput` for a centre or factor not finite, or a factor of zero (a map with
|
||||
/// no inverse).
|
||||
pub fn uniform_scaling(centre: Point3, factor: f64) -> crate::error::Result<Self> {
|
||||
use crate::error::SolidError;
|
||||
if !(finite3(centre) && factor.is_finite()) || factor == 0.0 {
|
||||
return Err(SolidError::invalid_input(
|
||||
OP_MAPS,
|
||||
"a scale's numbers not finite, or a factor of zero",
|
||||
));
|
||||
}
|
||||
Ok(Self::about(
|
||||
centre,
|
||||
Vec3::new(factor, 0.0, 0.0),
|
||||
Vec3::new(0.0, factor, 0.0),
|
||||
Vec3::new(0.0, 0.0, factor),
|
||||
))
|
||||
}
|
||||
|
||||
/// The mirror in the plane through `origin` square to `normal` (any length, made unit
|
||||
/// here): `p' = p - 2 ((p - origin)·n) n`, its linear part `I - 2 n nᵀ` (symmetric, so
|
||||
/// the same in either convention) and its translation `2 (origin·n) n`. For a normal
|
||||
/// along an axis every entry is exact, so the mirror twice is the identity exactly.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `InvalidInput` for an origin or normal not finite, or a normal whose squared length
|
||||
/// is below [`EFF_ZERO_SQ`].
|
||||
pub fn reflection(origin: Point3, normal: Vec3) -> crate::error::Result<Self> {
|
||||
use crate::error::SolidError;
|
||||
if !(finite3(origin) && finite3(normal)) {
|
||||
return Err(SolidError::invalid_input(
|
||||
OP_MAPS,
|
||||
"a mirror's numbers not finite",
|
||||
));
|
||||
}
|
||||
let n = normal
|
||||
.unitize()
|
||||
.map_err(|_| SolidError::invalid_input(OP_MAPS, "a mirror's normal of no length"))?;
|
||||
let row = |e: Vec3, ni: f64| e - n * (2.0 * ni);
|
||||
Ok(Self::from_axes(
|
||||
row(Vec3::X, n.x),
|
||||
row(Vec3::Y, n.y),
|
||||
row(Vec3::Z, n.z),
|
||||
n * (2.0 * origin.dot(n)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The largest difference between two matrices' entries.
|
||||
fn apart(a: &Transform, b: &Transform) -> f64 {
|
||||
a.m.iter()
|
||||
.flatten()
|
||||
.zip(b.m.iter().flatten())
|
||||
.map(|(x, y)| (x - y).abs())
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_about_z_takes_x_to_y_and_keeps_its_line() {
|
||||
use crate::geom::certified::Verdict;
|
||||
let q = core::f64::consts::FRAC_PI_2;
|
||||
let turn = Transform::rotation(Point3::ZERO, Vec3::new(0.0, 0.0, 2.0), q).unwrap();
|
||||
let want = Transform::from_axes(Vec3::Y, -Vec3::X, Vec3::Z, Point3::ZERO);
|
||||
assert!(apart(&turn, &want) <= 1e-15, "{turn:?}");
|
||||
// about the line through (10, 0, 0) along z: that point stays, (11, 0, 0) goes to
|
||||
// (10, 1, 0)
|
||||
let p = Point3::new(10.0, 0.0, 0.0);
|
||||
let turn = Transform::rotation(p, Vec3::Z, q).unwrap();
|
||||
assert!(turn.transform_point(p).distance(p) <= 1e-14);
|
||||
let image = turn.transform_point(Point3::new(11.0, 0.0, 0.0));
|
||||
assert!(
|
||||
image.distance(Point3::new(10.0, 1.0, 0.0)) <= 1e-14,
|
||||
"{image:?}"
|
||||
);
|
||||
// a turn about a skew axis keeps lengths and its axis
|
||||
let axis = Vec3::new(1.0, 2.0, 2.0);
|
||||
let turn = Transform::rotation(p, axis, 0.7).unwrap();
|
||||
assert!(
|
||||
turn.transform_point(p + axis * 3.0)
|
||||
.distance(p + axis * 3.0)
|
||||
<= 1e-12
|
||||
);
|
||||
for r in turn.row_lengths() {
|
||||
assert!((r - 1.0).abs() <= 1e-14);
|
||||
}
|
||||
assert_eq!(turn.is_regular(1e-9), Verdict::Yes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scale_keeps_its_centre_and_a_mirror_twice_is_the_identity() {
|
||||
let c = Point3::new(1.0, -2.0, 3.0);
|
||||
let scale = Transform::uniform_scaling(c, 2.5).unwrap();
|
||||
assert!(scale.transform_point(c).distance(c) <= 1e-14);
|
||||
let p = scale.transform_point(c + Vec3::X);
|
||||
assert!(p.distance(c + Vec3::X * 2.5) <= 1e-14, "{p:?}");
|
||||
// the mirror in y = 5, an axis normal of any length: every entry exact
|
||||
let mirror =
|
||||
Transform::reflection(Point3::new(7.0, 5.0, -1.0), Vec3::new(0.0, 3.0, 0.0)).unwrap();
|
||||
assert_eq!(
|
||||
mirror.transform_point(Point3::new(1.0, 2.0, 3.0)),
|
||||
Point3::new(1.0, 8.0, 3.0)
|
||||
);
|
||||
assert_eq!(mirror.concat(&mirror), Transform::IDENTITY);
|
||||
// a slanted mirror keeps its plane's points and reverses its normal
|
||||
let n = Vec3::new(1.0, 1.0, 0.0);
|
||||
let mirror = Transform::reflection(c, n).unwrap();
|
||||
assert!(mirror.transform_point(c).distance(c) <= 1e-14);
|
||||
let back = mirror.transform_point(c + n);
|
||||
assert!(back.distance(c - n) <= 1e-14, "{back:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_from_numbers_refuse_what_is_not_finite_or_has_no_size() {
|
||||
let bad = |r: crate::error::Result<Transform>| matches!(r, Err(crate::error::SolidError::InvalidInput { operation, .. }) if operation == OP_MAPS);
|
||||
assert!(bad(Transform::rotation(Point3::ZERO, Vec3::ZERO, 1.0)));
|
||||
assert!(bad(Transform::rotation(Point3::ZERO, Vec3::Z, f64::NAN)));
|
||||
assert!(bad(Transform::rotation(
|
||||
Point3::new(f64::INFINITY, 0.0, 0.0),
|
||||
Vec3::Z,
|
||||
1.0
|
||||
)));
|
||||
assert!(bad(Transform::uniform_scaling(Point3::ZERO, 0.0)));
|
||||
assert!(bad(Transform::uniform_scaling(Point3::ZERO, f64::NAN)));
|
||||
assert!(bad(Transform::reflection(Point3::ZERO, Vec3::ZERO)));
|
||||
assert!(bad(Transform::reflection(
|
||||
Point3::ZERO,
|
||||
Vec3::new(f64::NAN, 1.0, 0.0)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rigid_motion_is_regular_and_a_flattening_is_not() {
|
||||
use crate::geom::certified::Verdict;
|
||||
|
||||
-1
@@ -31,7 +31,6 @@ pub mod iges;
|
||||
pub(crate) mod index;
|
||||
pub mod knots;
|
||||
pub mod math;
|
||||
pub mod n_sided;
|
||||
pub mod nurbs_crv;
|
||||
pub mod nurbs_srf;
|
||||
pub mod spiral;
|
||||
|
||||
+5
-5
@@ -119,7 +119,7 @@ fn bad(reason: &'static str) -> SolidError {
|
||||
/// equal is the polynomial curve on its points divided by that weight, exactly (a record
|
||||
/// spline's kernel curve carries a weight of 1 on every point). Only unequal weights make it
|
||||
/// rational.
|
||||
pub(crate) fn nurb_of(c: &BSplineCurve) -> Result<Curve> {
|
||||
fn nurb_of(c: &BSplineCurve) -> Result<Curve> {
|
||||
let mut nurb = c
|
||||
.nurb
|
||||
.clone()
|
||||
@@ -182,7 +182,7 @@ fn surface(net: Vec<Vec<Vec3>>, p: usize, q: usize, u: &[f64], v: &[f64]) -> Res
|
||||
}
|
||||
|
||||
/// `c` moved by minus `o`, into the network's own frame.
|
||||
pub(crate) fn moved(c: &Curve, o: CPoint) -> Result<Curve> {
|
||||
fn moved(c: &Curve, o: CPoint) -> Result<Curve> {
|
||||
let pw = c
|
||||
.cpts()
|
||||
.iter()
|
||||
@@ -192,7 +192,7 @@ pub(crate) fn moved(c: &Curve, o: CPoint) -> Result<Curve> {
|
||||
}
|
||||
|
||||
/// `s` moved by minus `o`, into the network's own frame.
|
||||
pub(crate) fn moved_surface(s: &Surface, o: CPoint) -> Result<Surface> {
|
||||
fn moved_surface(s: &Surface, o: CPoint) -> Result<Surface> {
|
||||
let net = s
|
||||
.rows()
|
||||
.map(|row| {
|
||||
@@ -206,7 +206,7 @@ pub(crate) fn moved_surface(s: &Surface, o: CPoint) -> Result<Surface> {
|
||||
}
|
||||
|
||||
/// The largest coordinate of `points`.
|
||||
pub(crate) fn largest<'a>(points: impl Iterator<Item = &'a CPoint>) -> f64 {
|
||||
fn largest<'a>(points: impl Iterator<Item = &'a CPoint>) -> f64 {
|
||||
points
|
||||
.map(|c| c.x.abs().max(c.y.abs()).max(c.z.abs()))
|
||||
.fold(0.0_f64, f64::max)
|
||||
@@ -226,7 +226,7 @@ fn room<T>(count: usize) -> Result<Vec<T>> {
|
||||
}
|
||||
|
||||
/// Whether `x` is at most `tol`; a NaN is not.
|
||||
pub(crate) fn within(x: f64, tol: f64) -> bool {
|
||||
fn within(x: f64, tol: f64) -> bool {
|
||||
matches!(x.partial_cmp(&tol), Some(Ordering::Less | Ordering::Equal))
|
||||
}
|
||||
|
||||
|
||||
-311
@@ -1,311 +0,0 @@
|
||||
//! An n-sided patch bounded by curves meeting end to end round one loop, certified: the kernel's
|
||||
//! fill (`N_FillNSidedHole`, [`BSplineSurface::create_n_sided_patch`]) splits each boundary
|
||||
//! curve at its middle and fills the hole with N four-sided patches round a centre, each bounded
|
||||
//! by halves of two boundary curves and two inner curves from their middles to the centre.
|
||||
//!
|
||||
//! Upstream: none (not upstream; solidcore-native). The fill is upstream's; the certificate is
|
||||
//! this module's.
|
||||
//!
|
||||
//! | Rust | Upstream |
|
||||
//! |---------------|----------|
|
||||
//! | [`n_sided`] | none |
|
||||
//!
|
||||
//! What is certified, and what is not:
|
||||
//! - **The boundary**: each half of each boundary curve against the patch side it is (the side
|
||||
//! whose certified distance to it is least, either way round, both on `[0, 1]`):
|
||||
//! [`certified_distance`], plus the rounding of extracting the side and splitting the curve by
|
||||
//! knot insertion (`γ_(12 (d + 1))` of twice the largest coordinate each, a coordinate error
|
||||
//! `e` moving a point by at most `2 e`), counted in the loop's own frame (minus its first
|
||||
//! control point, each moved coordinate rounding once). The largest is returned.
|
||||
//! - **The inner joins (G0)**: every patch side that is no boundary half against the nearest
|
||||
//! side of another patch, certified the same way; the largest must be within the tolerance.
|
||||
//! - **Tangency across the inner curves (G1)** is the fill's aim, within its angle tolerance,
|
||||
//! and is **not** certified here.
|
||||
//!
|
||||
//! Deviations: none (no upstream counterpart). Non-rational curves only (a curve whose weights
|
||||
//! are all one value is its polynomial form). No recursion.
|
||||
|
||||
use crate::error::{Result, SolidError};
|
||||
use crate::nurbs::b_spline_curve::BSplineCurve;
|
||||
use crate::nurbs::b_spline_surface::BSplineSurface;
|
||||
use crate::nurbs::crv_basic::Curve;
|
||||
use crate::nurbs::crv_deviation::certified_distance;
|
||||
use crate::nurbs::geom::Interval;
|
||||
use crate::nurbs::gordon::{largest, moved, moved_surface, nurb_of, within};
|
||||
use crate::nurbs::math::gamma;
|
||||
use crate::nurbs::srf_basic::{Direction, Surface};
|
||||
use crate::nurbs::srf_tool::p1::extract_iso_crv;
|
||||
|
||||
const OP: &str = "nurbs::n_sided::n_sided";
|
||||
|
||||
/// The fewest curves of an n-sided loop.
|
||||
pub const MIN_SIDES: usize = 3;
|
||||
/// The most curves of an n-sided loop.
|
||||
pub const MAX_SIDES: usize = 64;
|
||||
|
||||
/// The angle (degrees) within which the fill matches the cross-boundary derivatives it builds
|
||||
/// across its inner curves: the fill's own tests' value.
|
||||
const ANGLE_DEG: f64 = 1.0;
|
||||
|
||||
/// The N patches of an n-sided fill and its certificates.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NSided {
|
||||
/// The patches, one per boundary curve.
|
||||
pub patches: Vec<BSplineSurface>,
|
||||
/// The largest certified distance between a boundary curve's half and its patch side.
|
||||
pub bound: f64,
|
||||
/// The largest certified distance between the two patch sides of an inner join.
|
||||
pub joins: f64,
|
||||
}
|
||||
|
||||
/// Why a loop has no certified n-sided patch here.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum NSidedRefusal {
|
||||
/// A curve is rational (its weights not all one value): the fill takes polynomial curves.
|
||||
Rational,
|
||||
/// A boundary curve is not proved within the tolerance of the patches: the bound.
|
||||
PastTolerance {
|
||||
/// The largest certified distance.
|
||||
bound: f64,
|
||||
},
|
||||
/// Two patches are not proved to meet within the tolerance along an inner curve: the
|
||||
/// largest certified gap.
|
||||
Joins {
|
||||
/// The largest certified gap between two patches' sides.
|
||||
gap: f64,
|
||||
},
|
||||
}
|
||||
|
||||
fn bad(reason: &'static str) -> SolidError {
|
||||
SolidError::invalid_input(OP, reason)
|
||||
}
|
||||
|
||||
/// `c` on `[0, 1]`, as it runs or reversed.
|
||||
fn on_unit(c: &Curve, reversed: bool) -> Result<Curve> {
|
||||
let mut c = c.clone();
|
||||
if reversed {
|
||||
c.reverse()?;
|
||||
}
|
||||
c.reparam_to_interval(Interval::new(0.0, 1.0))?;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// The four sides of patch `s`, each on `[0, 1]`.
|
||||
fn sides(s: &Surface) -> Result<[Curve; 4]> {
|
||||
let b = s.parameter_bounds();
|
||||
Ok([
|
||||
on_unit(&extract_iso_crv(s, b.vb, Direction::U)?, false)?,
|
||||
on_unit(&extract_iso_crv(s, b.vt, Direction::U)?, false)?,
|
||||
on_unit(&extract_iso_crv(s, b.ul, Direction::V)?, false)?,
|
||||
on_unit(&extract_iso_crv(s, b.ur, Direction::V)?, false)?,
|
||||
])
|
||||
}
|
||||
|
||||
/// The least certified distance from `c` (on `[0, 1]`) to `side` either way round.
|
||||
fn nearest(c: &Curve, side: &Curve) -> Result<f64> {
|
||||
let forward = certified_distance(c, side)?;
|
||||
let backward = certified_distance(c, &on_unit(side, true)?)?;
|
||||
Ok(forward.min(backward))
|
||||
}
|
||||
|
||||
/// The n-sided fill of `curves` (each running on from the one before, the last back to the
|
||||
/// first), its boundary and inner joins certified within `tol` (module docs).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `InvalidInput` for fewer than [`MIN_SIDES`] or more than [`MAX_SIDES`] curves, a curve
|
||||
/// without its NURB or `tol` not finite and positive; those of the fill (curves that do not
|
||||
/// meet within its model tolerance, ...), the extraction, the split and the certificates.
|
||||
pub fn n_sided(
|
||||
curves: &[BSplineCurve],
|
||||
tol: f64,
|
||||
) -> Result<std::result::Result<NSided, NSidedRefusal>> {
|
||||
if !(MIN_SIDES..=MAX_SIDES).contains(&curves.len()) {
|
||||
return Err(bad("fewer than three or more than 64 curves"));
|
||||
}
|
||||
if !(tol.is_finite() && tol > 0.0) {
|
||||
return Err(bad("a tolerance not finite and positive"));
|
||||
}
|
||||
let nurbs: Vec<Curve> = curves.iter().map(nurb_of).collect::<Result<_>>()?;
|
||||
if nurbs.iter().any(Curve::is_rational) {
|
||||
return Ok(Err(NSidedRefusal::Rational));
|
||||
}
|
||||
|
||||
// the fill, on copies of the polynomial curves (it changes its inputs)
|
||||
let mut bounding: Vec<BSplineCurve> = nurbs
|
||||
.iter()
|
||||
.map(|c| BSplineCurve::from_gw_nurb(3, c))
|
||||
.collect();
|
||||
let mut across: Vec<Option<BSplineCurve>> = bounding.iter().map(|_| None).collect();
|
||||
let patches =
|
||||
BSplineSurface::create_n_sided_patch(&mut bounding, &mut across, ANGLE_DEG, None)?;
|
||||
|
||||
// the loop's own frame
|
||||
let origin = nurbs
|
||||
.first()
|
||||
.and_then(|c| c.cpts().first().copied())
|
||||
.ok_or_else(|| bad("a curve without points"))?;
|
||||
let curves_local: Vec<Curve> = nurbs
|
||||
.iter()
|
||||
.map(|c| moved(c, origin))
|
||||
.collect::<Result<_>>()?;
|
||||
let mut local: Vec<Surface> = Vec::new();
|
||||
for p in &patches {
|
||||
let s = p
|
||||
.nurb
|
||||
.as_ref()
|
||||
.ok_or_else(|| bad("a patch without its NURB"))?;
|
||||
local.push(moved_surface(s, origin)?);
|
||||
}
|
||||
let size = largest(
|
||||
curves_local
|
||||
.iter()
|
||||
.flat_map(Curve::cpts)
|
||||
.chain(local.iter().flat_map(Surface::rows).flatten()),
|
||||
);
|
||||
let degree = curves_local
|
||||
.iter()
|
||||
.map(Curve::degree)
|
||||
.chain(local.iter().map(|s| {
|
||||
let (p, q) = s.degrees();
|
||||
p.max(q)
|
||||
}))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
// splitting a curve and extracting a side (knot insertion each), and the two moves
|
||||
let rounding = 2.0 * (4.0 * gamma(12 * (degree + 1)) * size) + 2.0 * (2.0 * gamma(1) * size);
|
||||
|
||||
let all_sides: Vec<[Curve; 4]> = local.iter().map(sides).collect::<Result<_>>()?;
|
||||
// which patch sides are boundary halves
|
||||
let mut boundary_side = vec![[false; 4]; all_sides.len()];
|
||||
let mut bound = 0.0_f64;
|
||||
for c in &curves_local {
|
||||
let (a, b) = c.param_bounds();
|
||||
let (first, second) = c.split(0.5 * (a + b))?;
|
||||
for half in [first, second] {
|
||||
let half = on_unit(&half, false)?;
|
||||
let mut best: Option<(f64, usize, usize)> = None;
|
||||
for (i, four) in all_sides.iter().enumerate() {
|
||||
for (j, side) in four.iter().enumerate() {
|
||||
let d = nearest(&half, side)?;
|
||||
if best.is_none_or(|(b, _, _)| d < b) {
|
||||
best = Some((d, i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((d, i, j)) = best else {
|
||||
return Err(bad("a fill without patches"));
|
||||
};
|
||||
if let Some(flag) = boundary_side.get_mut(i).and_then(|f| f.get_mut(j)) {
|
||||
*flag = true;
|
||||
}
|
||||
bound = bound.max((d + rounding) * (1.0 + gamma(3)));
|
||||
}
|
||||
}
|
||||
if !within(bound, tol) {
|
||||
return Ok(Err(NSidedRefusal::PastTolerance { bound }));
|
||||
}
|
||||
|
||||
// the inner joins: each side that is no boundary half against the nearest side of another
|
||||
// patch
|
||||
let mut joins = 0.0_f64;
|
||||
for (i, four) in all_sides.iter().enumerate() {
|
||||
for (j, side) in four.iter().enumerate() {
|
||||
if boundary_side
|
||||
.get(i)
|
||||
.and_then(|f| f.get(j))
|
||||
.copied()
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut best = f64::INFINITY;
|
||||
for (other, theirs) in all_sides.iter().enumerate() {
|
||||
if other == i {
|
||||
continue;
|
||||
}
|
||||
for s in theirs {
|
||||
best = best.min(nearest(side, s)?);
|
||||
}
|
||||
}
|
||||
joins = joins.max((best + rounding) * (1.0 + gamma(3)));
|
||||
}
|
||||
}
|
||||
if !within(joins, tol) {
|
||||
return Ok(Err(NSidedRefusal::Joins { gap: joins }));
|
||||
}
|
||||
Ok(Ok(NSided {
|
||||
patches,
|
||||
bound,
|
||||
joins,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::Vec3;
|
||||
use crate::nurbs::math::CPoint;
|
||||
|
||||
/// The polynomial curve of degree `p` on `points` with clamped uniform knots.
|
||||
fn spline(points: &[[f64; 3]], p: usize) -> BSplineCurve {
|
||||
let n = points.len();
|
||||
let mut knots = vec![0.0; p + 1];
|
||||
for i in 1..n - p {
|
||||
knots.push(i as f64 / (n - p) as f64);
|
||||
}
|
||||
knots.extend(vec![1.0; p + 1]);
|
||||
let pw = points
|
||||
.iter()
|
||||
.map(|q| CPoint::from_point(Vec3::new(q[0], q[1], q[2])))
|
||||
.collect();
|
||||
BSplineCurve::from_gw_nurb(3, &Curve::new(pw, p, knots).unwrap())
|
||||
}
|
||||
|
||||
/// The corners of a regular pentagon of radius 10 in z = 0.
|
||||
fn pentagon() -> Vec<[f64; 3]> {
|
||||
(0..5)
|
||||
.map(|i| {
|
||||
let a = std::f64::consts::TAU * f64::from(i) / 5.0;
|
||||
[10.0 * a.cos(), 10.0 * a.sin(), 0.0]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pentagon_of_lines_is_filled_flat() {
|
||||
let p = pentagon();
|
||||
let lines: Vec<BSplineCurve> = (0..5).map(|i| spline(&[p[i], p[(i + 1) % 5]], 1)).collect();
|
||||
let f = n_sided(&lines, 1e-6).unwrap().unwrap();
|
||||
assert_eq!(f.patches.len(), 5);
|
||||
assert!(
|
||||
f.bound <= 1e-9 && f.joins <= 1e-9,
|
||||
"{} {}",
|
||||
f.bound,
|
||||
f.joins
|
||||
);
|
||||
// planar data, planar patches: every control point in z = 0
|
||||
for s in &f.patches {
|
||||
for c in s.nurb.as_ref().unwrap().rows().flatten() {
|
||||
assert!(c.z.abs() < 1e-9, "{c:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rational_curves_are_refused() {
|
||||
let p = pentagon();
|
||||
let mut curves: Vec<BSplineCurve> =
|
||||
(0..5).map(|i| spline(&[p[i], p[(i + 1) % 5]], 1)).collect();
|
||||
let pw = vec![
|
||||
CPoint::new(p[0][0], p[0][1], 0.0, 1.0),
|
||||
CPoint::new(2.0 * p[1][0], 2.0 * p[1][1], 0.0, 2.0),
|
||||
];
|
||||
curves[0] =
|
||||
BSplineCurve::from_gw_nurb(3, &Curve::new(pw, 1, vec![0.0, 0.0, 1.0, 1.0]).unwrap());
|
||||
assert_eq!(
|
||||
n_sided(&curves, 1e-6).unwrap().unwrap_err(),
|
||||
NSidedRefusal::Rational
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-116
@@ -42,8 +42,6 @@
|
||||
//! | [`Surface::average_len`], [`AverageLen`] | `N_SrfGetAverageLen` |
|
||||
//! | [`Direction`] | `NL_UDIR` / `NL_VDIR` / `NL_UVDIR` |
|
||||
//! | [`WEIGHT_MIN`], [`WEIGHT_MAX`], [`WEIGHT_TOL`] | `NL_WMIN`, `NL_WMAX`, `NL_WTOL` |
|
||||
//! | [`Surface::displace_cpts`] | none |
|
||||
//! | [`falloff_weight`], [`FalloffShape`] | none |
|
||||
//!
|
||||
//! Deviations:
|
||||
//! - [`Surface`] owns its control net and knot arrays (upstream points at caller- or
|
||||
@@ -94,7 +92,7 @@ use crate::nurbs::geom::{
|
||||
dist_pt_pt, dist_signed_pt_plane, plane_pt_normal_to_implicit, Rectangle,
|
||||
};
|
||||
use crate::nurbs::knots::{KnotVector, MAX_DEGREE};
|
||||
use crate::nurbs::math::{combine2_pts, combine4_pts, CPoint, MODEL_TOL, NOW, NOZ};
|
||||
use crate::nurbs::math::{combine2_pts, combine4_pts, CPoint, MODEL_TOL, NOW};
|
||||
|
||||
/// Minimum valid weight (`NL_WMIN`).
|
||||
pub const WEIGHT_MIN: f64 = 1.0e-03;
|
||||
@@ -109,7 +107,6 @@ const OP_NEW: &str = "nurbs::srf_basic::Surface::new";
|
||||
const MIXED_RATIONALITY: &str = "control points mix rational and non-rational";
|
||||
const OP_WITH_INDICES: &str = "nurbs::srf_basic::Surface::with_indices";
|
||||
const OP_CPT: &str = "nurbs::srf_basic::Surface::cpt";
|
||||
const OP_DISPLACE: &str = "nurbs::srf_basic::Surface::displace_cpts";
|
||||
const OP_SET_CPTS: &str = "nurbs::srf_basic::Surface::set_cpts_and_knots";
|
||||
const OP_SET_SIZE: &str = "nurbs::srf_basic::Surface::set_size_indices";
|
||||
const OP_COUNTS: &str = "nurbs::srf_basic::Surface::counts_are_valid";
|
||||
@@ -452,45 +449,6 @@ impl Surface {
|
||||
self.rows().map(<[CPoint]>::to_vec).collect()
|
||||
}
|
||||
|
||||
/// Moves control points by fractions of one displacement (not upstream): each
|
||||
/// `((i, j), t)` of `moves` moves the Euclidean point of `Pw[i][j]` by `t * delta`, its
|
||||
/// weight kept (a rational point's weighted coordinates move by `w t delta`). A point
|
||||
/// listed twice moves by the sum of its fractions. All or nothing: on a refusal the net
|
||||
/// is unchanged.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `InvalidInput` for an index past the net, a fraction or `delta` not finite, a 2D net
|
||||
/// (no `z`), or a moved point not finite.
|
||||
pub fn displace_cpts(&mut self, moves: &[((usize, usize), f64)], delta: Vec3) -> Result<()> {
|
||||
if !(delta.x.is_finite() && delta.y.is_finite() && delta.z.is_finite()) {
|
||||
return Err(invalid(OP_DISPLACE, "a displacement not finite"));
|
||||
}
|
||||
let mut next = self.clone();
|
||||
for &((i, j), t) in moves {
|
||||
if !t.is_finite() {
|
||||
return Err(invalid(OP_DISPLACE, "a fraction not finite"));
|
||||
}
|
||||
let c = next.cpt_mut(i, j)?;
|
||||
if c.z == NOZ {
|
||||
return Err(invalid(OP_DISPLACE, "a 2D control net"));
|
||||
}
|
||||
let w = if c.w == NOW { 1.0 } else { c.w };
|
||||
let moved = CPoint {
|
||||
x: c.x + w * t * delta.x,
|
||||
y: c.y + w * t * delta.y,
|
||||
z: c.z + w * t * delta.z,
|
||||
w: c.w,
|
||||
};
|
||||
if !(moved.x.is_finite() && moved.y.is_finite() && moved.z.is_finite()) {
|
||||
return Err(invalid(OP_DISPLACE, "a moved point not finite"));
|
||||
}
|
||||
*c = moved;
|
||||
}
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The u knots `U[0..=r]` (`N_SrfGetKnots`).
|
||||
#[must_use]
|
||||
pub fn u_knots(&self) -> &[f64] {
|
||||
@@ -1137,31 +1095,6 @@ fn rescale(u: &mut [f64], r: usize, p: usize, lo: f64, hi: f64) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How a soft move's share falls with a control point's index distance from the moved points
|
||||
/// (not upstream), with `s = d / (radius + 1)` for a point `d` index steps away.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FalloffShape {
|
||||
/// `1 - s`.
|
||||
Linear,
|
||||
/// `1 - (3 s² - 2 s³)`: flat at the moved points and at the radius's edge.
|
||||
Smooth,
|
||||
}
|
||||
|
||||
/// The fraction of a soft move ([`Surface::displace_cpts`]) taken by a control point `d` index
|
||||
/// steps from the nearest moved point (not upstream): 1 at `d = 0`, falling by `shape` with
|
||||
/// `s = d / (radius + 1)`, and 0 past `radius`.
|
||||
#[must_use]
|
||||
pub fn falloff_weight(shape: FalloffShape, radius: u32, d: u32) -> f64 {
|
||||
if d > radius {
|
||||
return 0.0;
|
||||
}
|
||||
let s = f64::from(d) / (f64::from(radius) + 1.0);
|
||||
match shape {
|
||||
FalloffShape::Linear => 1.0 - s,
|
||||
FalloffShape::Smooth => 1.0 - s * s * (3.0 - 2.0 * s),
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the surfaces rationality-compatible (`N_MakeSrfsRatCompatible`): when any
|
||||
/// is rational, every non-rational one gets weights `1.0`.
|
||||
pub fn make_rat_compatible(surfaces: &mut [Surface]) {
|
||||
@@ -1290,54 +1223,6 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falloff_is_one_at_the_moved_points_and_zero_past_the_radius() {
|
||||
for shape in [FalloffShape::Linear, FalloffShape::Smooth] {
|
||||
assert_eq!(falloff_weight(shape, 0, 0), 1.0);
|
||||
assert_eq!(falloff_weight(shape, 0, 1), 0.0);
|
||||
assert_eq!(falloff_weight(shape, 3, 0), 1.0);
|
||||
assert_eq!(falloff_weight(shape, 3, 4), 0.0);
|
||||
assert_eq!(falloff_weight(shape, 3, u32::MAX), 0.0);
|
||||
let w: Vec<f64> = (0..=3).map(|d| falloff_weight(shape, 3, d)).collect();
|
||||
assert!(w.windows(2).all(|p| p[1] < p[0]), "{shape:?}: {w:?}");
|
||||
}
|
||||
// s = 1/4 at one step of radius 3
|
||||
assert_eq!(falloff_weight(FalloffShape::Linear, 3, 1), 0.75);
|
||||
assert_eq!(
|
||||
falloff_weight(FalloffShape::Smooth, 3, 1),
|
||||
1.0 - 0.0625 * 2.5
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn displaced_points_move_by_their_fraction_of_the_delta() {
|
||||
let mut s = bilinear();
|
||||
let d = Vec3::new(0.0, 0.0, 2.0);
|
||||
// a point listed twice moves by the sum of its fractions
|
||||
s.displace_cpts(&[((1, 1), 0.5), ((0, 0), 1.0), ((1, 1), 0.25)], d)
|
||||
.unwrap();
|
||||
assert_eq!(s.cpt(0, 0).unwrap(), CPoint::new(0.0, 0.0, 2.0, NOW));
|
||||
assert_eq!(s.cpt(1, 1).unwrap(), CPoint::new(1.0, 1.0, 1.5, NOW));
|
||||
assert_eq!(s.cpt(0, 1).unwrap(), CPoint::new(0.0, 1.0, 0.0, NOW));
|
||||
assert_eq!(s.cpt(1, 0).unwrap(), CPoint::new(1.0, 0.0, 0.0, NOW));
|
||||
// a rational point keeps its weight; its weighted coordinates move by w t delta
|
||||
let mut r = bilinear();
|
||||
r.non_rat_to_rat();
|
||||
*r.cpt_mut(1, 0).unwrap() = CPoint::new(2.0, 0.0, 0.0, 2.0);
|
||||
r.displace_cpts(&[((1, 0), 1.0)], d).unwrap();
|
||||
assert_eq!(r.cpt(1, 0).unwrap(), CPoint::new(2.0, 0.0, 4.0, 2.0));
|
||||
// all or nothing: a bad move after a good one leaves the net as it was
|
||||
let before = bilinear();
|
||||
let mut t = before.clone();
|
||||
assert!(t.displace_cpts(&[((0, 0), 1.0), ((2, 0), 1.0)], d).is_err());
|
||||
assert!(t.displace_cpts(&[((0, 0), f64::NAN)], d).is_err());
|
||||
assert!(t
|
||||
.displace_cpts(&[((0, 0), 1.0)], Vec3::new(f64::INFINITY, 0.0, 0.0))
|
||||
.is_err());
|
||||
assert!(t.displace_cpts(&[((0, 0), f64::MAX)], d).is_err());
|
||||
assert_eq!(t, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construct_and_access() {
|
||||
let s = bilinear();
|
||||
|
||||
@@ -219,10 +219,6 @@ pub struct OffsetExecutive {
|
||||
/// face and by each of its face uses, recorded as the face is offset.
|
||||
extension_reaches: BTreeMap<FaceId, f64>,
|
||||
faceuse_extension_reaches: BTreeMap<FaceuseId, f64>,
|
||||
/// Not upstream: the edge bounds the local operation's reaches have found, each edge's
|
||||
/// taken again from here for its other face ([`super::reach::EdgeBounds`]); forgotten
|
||||
/// when the local surfaces are set and when the relations are cleared.
|
||||
edge_bounds: super::reach::EdgeBounds,
|
||||
/// Not upstream: the faces of a local operation that move to surfaces of their own
|
||||
/// (the others keep theirs), for their neighbours' reach
|
||||
/// ([`super::reach::local_extension_reach`]).
|
||||
@@ -293,7 +289,6 @@ impl OffsetExecutive {
|
||||
extended_surfaces: BTreeMap::new(),
|
||||
extension_reaches: BTreeMap::new(),
|
||||
faceuse_extension_reaches: BTreeMap::new(),
|
||||
edge_bounds: super::reach::EdgeBounds::default(),
|
||||
moved_faces: Vec::new(),
|
||||
kept_edges: BTreeSet::new(),
|
||||
kept_edgeuses: BTreeSet::new(),
|
||||
@@ -411,7 +406,6 @@ impl OffsetExecutive {
|
||||
self.local_faces = faces.to_vec();
|
||||
self.local_surfaces = surfaces;
|
||||
self.moved_faces = moved.to_vec();
|
||||
self.edge_bounds.clear();
|
||||
}
|
||||
|
||||
/// Not upstream: in a local operation, records the edges of `original` that keep their
|
||||
@@ -614,7 +608,6 @@ impl OffsetExecutive {
|
||||
self.blend_to_faces.remove_all();
|
||||
self.extension_reaches.clear();
|
||||
self.faceuse_extension_reaches.clear();
|
||||
self.edge_bounds.clear();
|
||||
}
|
||||
|
||||
/// Records how far the surface of original face `face` must be extended for its
|
||||
@@ -676,7 +669,6 @@ impl OffsetExecutive {
|
||||
&moved,
|
||||
&kept,
|
||||
self.tangency_tol_radians,
|
||||
&mut self.edge_bounds,
|
||||
)?
|
||||
// A new edge must cross each neighbouring surface's boundary, not sit on it:
|
||||
// the mate and the intersection identify points within the body's tolerance,
|
||||
@@ -4146,20 +4138,6 @@ mod tests {
|
||||
assert_eq!(executive.kept_edgeuses.len(), 16);
|
||||
}
|
||||
|
||||
/// The reach's edge bounds ([`super::super::reach::EdgeBounds`]) live for one local
|
||||
/// operation: setting its surfaces forgets them, and so does clearing its relations.
|
||||
#[test]
|
||||
fn a_local_operations_edge_bounds_are_forgotten_with_its_surfaces() {
|
||||
let mut executive = OffsetExecutive::new(OffsetOperation::LocalOperation, 0.125, 1.0e-6);
|
||||
executive.edge_bounds.hold(EdgeId::from_index(3));
|
||||
assert_eq!(executive.edge_bounds.held(), 1);
|
||||
executive.set_local_surfaces(&[], Vec::new(), &[]);
|
||||
assert_eq!(executive.edge_bounds.held(), 0);
|
||||
executive.edge_bounds.hold(EdgeId::from_index(3));
|
||||
executive.clear_relations();
|
||||
assert_eq!(executive.edge_bounds.held(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_face_margin_is_its_offset_corner_reach() {
|
||||
let mut kernel = Kernel::default();
|
||||
|
||||
+77
-487
@@ -56,7 +56,6 @@ use crate::surface::side::BoxSide;
|
||||
use crate::surface::surface::{Surface, SurfaceKind};
|
||||
use crate::topology::edgeuse::{p1 as eu, p3 as eu3};
|
||||
use crate::topology::{edge, loopuse, Brep, EdgeuseId, FaceId, FaceuseId};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
mod net;
|
||||
|
||||
@@ -842,8 +841,7 @@ impl Dv {
|
||||
}
|
||||
|
||||
/// The trace of a plane in a normal section, or of two planes' line: the foot `x0` of the
|
||||
/// edge point on it, its squared distance `foot2` from the point, and its direction `u`,
|
||||
/// unit only within rounding (a point `x0 + τ u` is `|τ| |u|` from the foot, not `|τ|`).
|
||||
/// edge point on it, its squared distance `foot2` from the point, and its unit direction.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Trace {
|
||||
x0: Dv,
|
||||
@@ -1109,11 +1107,8 @@ fn plan_of(line: &Trace, q: &AlongLine, quadric: &Implicit, tolerance: f64) -> P
|
||||
|
||||
/// The squared distances from the edge point of the meetings `plan` takes (each a bound on
|
||||
/// the reach: the meeting taken is the nearest), or `None` where the plan takes none or its
|
||||
/// formula is not defined on these enclosures. A meeting at `τ` is `foot² + τ² |u|²` from
|
||||
/// the point: `|u|²` enclosed as `u·u`, since the direction is unit only within rounding,
|
||||
/// and an upper bound taken as if `|u| = 1` could sit an ulp below the distance.
|
||||
/// formula is not defined on these enclosures.
|
||||
fn planned(plan: &Plan, line: &Trace, q: &AlongLine) -> Option<Vec<Di>> {
|
||||
let unit = line.u.dot(line.u);
|
||||
match plan {
|
||||
Plan::Roots {
|
||||
positive,
|
||||
@@ -1124,7 +1119,7 @@ fn planned(plan: &Plan, line: &Trace, q: &AlongLine) -> Option<Vec<Di>> {
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let tau = taus.get(i).copied().flatten()?;
|
||||
Some(line.foot2 + tau * tau * unit)
|
||||
Some(line.foot2 + tau * tau)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1133,7 +1128,7 @@ fn planned(plan: &Plan, line: &Trace, q: &AlongLine) -> Option<Vec<Di>> {
|
||||
return None;
|
||||
}
|
||||
let tau = (Di::constant(0.0) - q.b) / (Di::constant(2.0) * q.a);
|
||||
Some(vec![line.foot2 + tau * tau * unit])
|
||||
Some(vec![line.foot2 + tau * tau])
|
||||
}
|
||||
Plan::Cover => {
|
||||
if !q.a.excludes_zero() {
|
||||
@@ -1151,7 +1146,7 @@ fn planned(plan: &Plan, line: &Trace, q: &AlongLine) -> Option<Vec<Di>> {
|
||||
v: Ivl::from(tau),
|
||||
d: Ivl::whole(),
|
||||
};
|
||||
Some(vec![line.foot2 + tau * tau * unit])
|
||||
Some(vec![line.foot2 + tau * tau])
|
||||
}
|
||||
Plan::Apart | Plan::Unknown => None,
|
||||
}
|
||||
@@ -1408,118 +1403,6 @@ fn point_and_tangent(cn: &NurbsCurve, s: Ivl) -> Result<Option<(Dv, Dv)>> {
|
||||
Ok(Some((Dv::of(d0, d1), Dv::of(d1, d2))))
|
||||
}
|
||||
|
||||
/// What an edge's bound in [`local_extension_reach`] is of: a moved face's surface's distance
|
||||
/// from the edge (the shift), or how far past the edge a plane and a quadric beside it meet
|
||||
/// (the reach), by the faces whose surfaces they are, the pair in id order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum EdgeQuantity {
|
||||
Shift(Option<FaceId>),
|
||||
Meeting(Option<FaceId>, Option<FaceId>),
|
||||
}
|
||||
|
||||
/// A bound [`edge_interval_maximum`] found, with what it was found on, as bits
|
||||
/// ([`bound_inputs`]).
|
||||
#[derive(Debug, Clone)]
|
||||
struct EdgeBound {
|
||||
on: Vec<u64>,
|
||||
bound: Option<(f64, bool)>,
|
||||
}
|
||||
|
||||
/// Everything an edge's bound is a function of besides the quantity, as bits: the edge's
|
||||
/// interval, the tolerance, the curve's degree, knots and homogeneous control points (each
|
||||
/// list after its length), and the bits of the surfaces the quantity is of ([`exact_bits`]).
|
||||
/// Two asks with the same bits ask the same function of the same numbers.
|
||||
fn bound_inputs(
|
||||
cn: &NurbsCurve,
|
||||
interval: crate::geom::extent1d::Extent1d,
|
||||
tolerance: f64,
|
||||
surfaces: &[u64],
|
||||
) -> Vec<u64> {
|
||||
let count = |n: usize| u64::try_from(n).unwrap_or(u64::MAX);
|
||||
let mut on = vec![
|
||||
interval.min().to_bits(),
|
||||
interval.max().to_bits(),
|
||||
tolerance.to_bits(),
|
||||
count(cn.p),
|
||||
count(cn.n),
|
||||
count(cn.u.len()),
|
||||
];
|
||||
on.extend(cn.u.iter().map(|x| x.to_bits()));
|
||||
on.push(count(cn.pw.len()));
|
||||
on.extend(cn.pw.iter().map(|x| x.to_bits()));
|
||||
on.push(count(surfaces.len()));
|
||||
on.extend_from_slice(surfaces);
|
||||
on
|
||||
}
|
||||
|
||||
/// The bits of a surface's exact class an edge's bound is of ([`EdgeBounds`]): a tag for
|
||||
/// its kind, then its data; `None` for a class no edge's bound by pieces is of (a plane as
|
||||
/// an [`Implicit`], a torus, a B-spline face), which is then bounded and not recorded.
|
||||
fn exact_bits(c: &Exact) -> Option<Vec<u64>> {
|
||||
let v = |p: Vec3| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()];
|
||||
let mut bits = Vec::new();
|
||||
match *c {
|
||||
Exact::Plane(t) => {
|
||||
bits.push(0);
|
||||
bits.extend(v(t.point));
|
||||
bits.extend(v(t.normal));
|
||||
bits.extend(v(t.raw));
|
||||
}
|
||||
Exact::Quadric(Implicit::Cone {
|
||||
origin,
|
||||
axis,
|
||||
radius,
|
||||
tan_angle,
|
||||
}) => {
|
||||
bits.push(1);
|
||||
bits.extend(v(origin));
|
||||
bits.extend(v(axis));
|
||||
bits.extend([radius.to_bits(), tan_angle.to_bits()]);
|
||||
}
|
||||
Exact::Quadric(Implicit::Sphere { centre, radius }) => {
|
||||
bits.push(2);
|
||||
bits.extend(v(centre));
|
||||
bits.push(radius.to_bits());
|
||||
}
|
||||
Exact::Quadric(Implicit::Plane { .. } | Implicit::Torus { .. }) | Exact::Net(_) => {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(bits)
|
||||
}
|
||||
|
||||
/// The edge bounds one local operation has found ([`local_extension_reach`]), by edge and
|
||||
/// quantity: an edge is met from both its faces, and the second takes the first's bound
|
||||
/// rather than bounding the edge again, the same function of the same curve, so the same
|
||||
/// bits. Kept by the operation (the offset executive, from its local surfaces being set
|
||||
/// until its relations are cleared), never across operations.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EdgeBounds(BTreeMap<(crate::topology::EdgeId, EdgeQuantity), EdgeBound>);
|
||||
|
||||
impl EdgeBounds {
|
||||
/// Forgets every bound.
|
||||
pub fn clear(&mut self) {
|
||||
self.0.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl EdgeBounds {
|
||||
/// How many bounds are held.
|
||||
pub(super) fn held(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
/// Holds a bound for edge `e` (its shift by no face, on no inputs).
|
||||
pub(super) fn hold(&mut self, e: crate::topology::EdgeId) {
|
||||
let bound = EdgeBound {
|
||||
on: Vec::new(),
|
||||
bound: None,
|
||||
};
|
||||
self.0.insert((e, EdgeQuantity::Shift(None)), bound);
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece of an edge's parameters `[t0, t1]` with a certified upper bound on it, ordered by
|
||||
/// that bound (the most is taken first in [`edge_interval_maximum`]); `halvable` false
|
||||
/// where a halving left a half undecided, so the piece keeps its own bound.
|
||||
@@ -1586,52 +1469,21 @@ fn spans(cn: &NurbsCurve, interval: crate::geom::extent1d::Extent1d) -> Vec<(f64
|
||||
/// spend them on the spans taken first and leave a later one undecided, refusing the whole
|
||||
/// edge, though every piece was bounded or apart within the caps. Taking the largest bound
|
||||
/// first spends them where the answer is.
|
||||
///
|
||||
/// Each edge is met from both its faces, and the second asks for the same bound: `bounds`
|
||||
/// holds what the local operation has found ([`EdgeBounds`]), and a bound is taken from it
|
||||
/// for the same quantity `of` where everything it is a function of has the same bits
|
||||
/// ([`bound_inputs`]: the curve, interval, tolerance, and `surfaces`, the bits of the
|
||||
/// surfaces `f` is of). With no `surfaces` the edge is bounded and nothing recorded.
|
||||
fn edge_interval_maximum(
|
||||
b: &Brep,
|
||||
e: crate::topology::EdgeId,
|
||||
f: &dyn Fn((Dv, Dv), (Dv, Dv), Ivl) -> PieceBound,
|
||||
tolerance: f64,
|
||||
bounds: &mut EdgeBounds,
|
||||
of: EdgeQuantity,
|
||||
surfaces: Option<&[u64]>,
|
||||
) -> Result<Option<(f64, bool)>> {
|
||||
let Some(cn) = edge_nurbs(b, e)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let interval = edge::interval(b, e)?;
|
||||
let Some(surfaces) = surfaces else {
|
||||
return interval_maximum(&cn, interval, f, tolerance);
|
||||
};
|
||||
let on = bound_inputs(&cn, interval, tolerance, surfaces);
|
||||
if let Some(found) = bounds.0.get(&(e, of)) {
|
||||
if found.on == on {
|
||||
return Ok(found.bound);
|
||||
}
|
||||
}
|
||||
let bound = interval_maximum(&cn, interval, f, tolerance)?;
|
||||
bounds.0.insert((e, of), EdgeBound { on, bound });
|
||||
Ok(bound)
|
||||
}
|
||||
|
||||
/// [`edge_interval_maximum`]'s bound over `interval` of the curve `cn`.
|
||||
fn interval_maximum(
|
||||
cn: &NurbsCurve,
|
||||
interval: crate::geom::extent1d::Extent1d,
|
||||
f: &dyn Fn((Dv, Dv), (Dv, Dv), Ivl) -> PieceBound,
|
||||
tolerance: f64,
|
||||
) -> Result<Option<(f64, bool)>> {
|
||||
let mut undecided = spans(cn, interval);
|
||||
let mut undecided = spans(&cn, edge::interval(b, e)?);
|
||||
// the bound on a piece; a bound that is not finite bounds nothing
|
||||
let bound_of = |t0: f64, t1: f64| -> Result<PieceBound> {
|
||||
let tm = 0.5 * (t0 + t1);
|
||||
let over = point_and_tangent(cn, Ivl::new(t0, t1))?;
|
||||
let mid = point_and_tangent(cn, Ivl::from(tm))?;
|
||||
let over = point_and_tangent(&cn, Ivl::new(t0, t1))?;
|
||||
let mid = point_and_tangent(&cn, Ivl::from(tm))?;
|
||||
Ok(match (over, mid) {
|
||||
(Some(over), Some(mid)) => match f(over, mid, Ivl::checked(t0 - tm, t1 - tm)) {
|
||||
PieceBound::Value { upper, .. } if !upper.is_finite() => PieceBound::Unknown,
|
||||
@@ -1836,14 +1688,18 @@ fn straight_edges_between(
|
||||
}
|
||||
|
||||
/// Where the line of a kept straight edge ([`StraightEdge`]) meets the plane `(o, ν)`,
|
||||
/// from the vertex `p`: [`line_meets_plane`] on the line from the edge's first control
|
||||
/// point along its unit direction (unit within rounding).
|
||||
/// from the vertex `p`: the line's point `x0 + τ u` (`x0` the foot of `p`, `u` the unit
|
||||
/// direction, `τ = (o − x0)·ν / (u·ν)`) at `√(|x0 − p|² + τ²)`, raised by the curve's
|
||||
/// distance from the line over the sine of their angle (`dev |ν| / |u·ν|`); nowhere near
|
||||
/// where the line is within `tangent_sine` of parallel to the plane (as two planes'
|
||||
/// line, [`planes_line`]); not decided where the enclosures do not settle it.
|
||||
fn straight_edge_meets_plane(
|
||||
p: Vec3,
|
||||
line: StraightEdge,
|
||||
plane: Tangent,
|
||||
tangent_sine: f64,
|
||||
) -> CornerMeeting {
|
||||
let pv = Dv::constant(p);
|
||||
let a = Dv::constant(line.a);
|
||||
let d = Dv::constant(line.b).sub(a);
|
||||
let dd = d.dot(d);
|
||||
@@ -1851,24 +1707,6 @@ fn straight_edge_meets_plane(
|
||||
return CornerMeeting::Uncertified;
|
||||
}
|
||||
let u = d.scale(Di::constant(1.0) / dd.sqrt());
|
||||
line_meets_plane(Dv::constant(p), a, u, line.dev, plane, tangent_sine)
|
||||
}
|
||||
|
||||
/// Where the line `a + s u` meets the plane `(o, ν)`, from `p`: its point `M = x0 + τ u`
|
||||
/// (`x0 = a + u (u·(p − a))`, the foot of `p` where `|u| = 1`, a point of the line in any
|
||||
/// case; `τ = (o − x0)·ν / (u·ν)`) at `|M − p|`, enclosed as it is, so it holds whatever
|
||||
/// `|u|` (the direction is unit only within rounding), raised by the curve's distance `dev`
|
||||
/// from the line over the sine of their angle, `|u·ν| / (|u| |ν|)`; nowhere near where the
|
||||
/// line is within `tangent_sine` of parallel to the plane (as two planes' line,
|
||||
/// [`planes_line`]); not decided where the enclosures do not settle it.
|
||||
fn line_meets_plane(
|
||||
pv: Dv,
|
||||
a: Dv,
|
||||
u: Dv,
|
||||
dev: f64,
|
||||
plane: Tangent,
|
||||
tangent_sine: f64,
|
||||
) -> CornerMeeting {
|
||||
let x0 = a.add(u.scale(pv.sub(a).dot(u)));
|
||||
let nu = Dv::constant(plane.raw);
|
||||
let nn = nu.dot(nu).sqrt();
|
||||
@@ -1880,9 +1718,9 @@ fn line_meets_plane(
|
||||
return CornerMeeting::Nowhere;
|
||||
}
|
||||
let tau = Dv::constant(plane.point).sub(x0).dot(nu) / un;
|
||||
let to = x0.add(u.scale(tau)).sub(pv);
|
||||
let far = to.dot(to).v.hi.max(0.0);
|
||||
let slack = (Ivl::from(dev) * nn.v * u.dot(u).v.sqrt() / Ivl::from(un.v.mig())).hi;
|
||||
let foot = x0.sub(pv);
|
||||
let far = (foot.dot(foot) + tau * tau).v.hi.max(0.0);
|
||||
let slack = (Ivl::from(line.dev) * nn.v / Ivl::from(un.v.mig())).hi;
|
||||
let reach = round_up(round_up(sqrt(far)) + slack);
|
||||
if reach.is_finite() {
|
||||
CornerMeeting::Within(reach)
|
||||
@@ -2031,9 +1869,6 @@ fn kept_straight_edge_corner(
|
||||
/// left undecided past the caps, has no certified bound here:
|
||||
/// [`OffsetFailure::ReachUncertified`].
|
||||
///
|
||||
/// An edge's bound by pieces is taken from `bounds` where the operation already found it from
|
||||
/// the edge's other face ([`EdgeBounds`]), and recorded there otherwise.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`OffsetFailure::ReachUncertified`] as above; [`SolidError::InvalidInput`] for a
|
||||
@@ -2047,7 +1882,6 @@ pub fn local_extension_reach(
|
||||
moved: &[(FaceId, &Surface)],
|
||||
kept: &[(FaceId, &Surface)],
|
||||
tangency_tol_radians: f64,
|
||||
bounds: &mut EdgeBounds,
|
||||
) -> Result<Option<LocalReach>> {
|
||||
let tangent_sine = tangency_tol_radians.abs().min(crate::math::PI / 2.0).sin();
|
||||
let tolerance = b.tolerance();
|
||||
@@ -2132,33 +1966,26 @@ pub fn local_extension_reach(
|
||||
let hull = |f: &dyn Fn(Vec3) -> f64| -> Result<f64> {
|
||||
edge_maximum(b, e, f, tolerance)?.ok_or_else(uncertified)
|
||||
};
|
||||
let pieces = |f: &dyn Fn((Dv, Dv), (Dv, Dv), Ivl) -> PieceBound,
|
||||
bounds: &mut EdgeBounds,
|
||||
of: EdgeQuantity,
|
||||
surfaces: Option<&[u64]>|
|
||||
-> Result<(f64, bool)> {
|
||||
edge_interval_maximum(b, e, f, tolerance, bounds, of, surfaces)?
|
||||
.ok_or_else(uncertified)
|
||||
};
|
||||
// a moved net's move is bounded by the meetings on it, below
|
||||
let shift_of =
|
||||
|c: &Exact, face: Option<FaceId>, bounds: &mut EdgeBounds| -> Result<Option<f64>> {
|
||||
match c {
|
||||
Exact::Plane(t) => hull(&|x| plane_distance(x, *t)).map(Some),
|
||||
Exact::Quadric(q) => {
|
||||
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
|
||||
quadric_distance_piece(over.0, mid.0, dt, q)
|
||||
};
|
||||
let surfaces = exact_bits(c);
|
||||
let of = EdgeQuantity::Shift(face);
|
||||
Ok(Some(pieces(&piece, bounds, of, surfaces.as_deref())?.0))
|
||||
}
|
||||
Exact::Net(_) => Ok(None),
|
||||
}
|
||||
let pieces =
|
||||
|f: &dyn Fn((Dv, Dv), (Dv, Dv), Ivl) -> PieceBound| -> Result<(f64, bool)> {
|
||||
edge_interval_maximum(b, e, f, tolerance)?.ok_or_else(uncertified)
|
||||
};
|
||||
// a moved net's move is bounded by the meetings on it, below
|
||||
let shift_of = |c: &Exact| -> Result<Option<f64>> {
|
||||
match c {
|
||||
Exact::Plane(t) => hull(&|x| plane_distance(x, *t)).map(Some),
|
||||
Exact::Quadric(q) => {
|
||||
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
|
||||
quadric_distance_piece(over.0, mid.0, dt, q)
|
||||
};
|
||||
Ok(Some(pieces(&piece)?.0))
|
||||
}
|
||||
Exact::Net(_) => Ok(None),
|
||||
}
|
||||
};
|
||||
let mut net_moved = false;
|
||||
if own_moved {
|
||||
match shift_of(&own, own_face, bounds)? {
|
||||
match shift_of(&own)? {
|
||||
Some(shift) => found.shift = found.shift.max(shift),
|
||||
None => net_moved = true,
|
||||
}
|
||||
@@ -2171,7 +1998,7 @@ pub fn local_extension_reach(
|
||||
}
|
||||
let other = class_of(b, other_face)?;
|
||||
if other_moved {
|
||||
match shift_of(&other, other_face, bounds)? {
|
||||
match shift_of(&other)? {
|
||||
Some(shift) => found.shift = found.shift.max(shift),
|
||||
None => net_moved = true,
|
||||
}
|
||||
@@ -2192,26 +2019,14 @@ pub fn local_extension_reach(
|
||||
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
|
||||
plane_quadric_piece(over, mid, dt, plane, q, tolerance)
|
||||
};
|
||||
let pair =
|
||||
EdgeQuantity::Meeting(own_face.min(other_face), own_face.max(other_face));
|
||||
// the plane's, then the quadric's: the same from either face
|
||||
let surfaces = exact_bits(&Exact::Plane(plane))
|
||||
.zip(exact_bits(&Exact::Quadric(*q)))
|
||||
.map(|(mut a, c)| {
|
||||
a.extend(c);
|
||||
a
|
||||
});
|
||||
pieces(&piece, bounds, pair, surfaces.as_deref())?
|
||||
pieces(&piece)?
|
||||
}
|
||||
(&Exact::Plane(plane), Exact::Net(n)) | (Exact::Net(n), &Exact::Plane(plane)) => {
|
||||
let guess = std::cell::Cell::new(None);
|
||||
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
|
||||
plane_net_piece(over, mid, dt, plane, n, &guess)
|
||||
};
|
||||
// a net's bound is not recorded: it carries no bits to key it on
|
||||
let pair =
|
||||
EdgeQuantity::Meeting(own_face.min(other_face), own_face.max(other_face));
|
||||
pieces(&piece, bounds, pair, None)?
|
||||
pieces(&piece)?
|
||||
}
|
||||
(Exact::Quadric(_) | Exact::Net(_), Exact::Quadric(_) | Exact::Net(_)) => {
|
||||
return Err(uncertified())
|
||||
@@ -2654,10 +2469,9 @@ mod tests {
|
||||
} else {
|
||||
box_face(&brep, n).1
|
||||
};
|
||||
let found =
|
||||
local_extension_reach(&k, &brep, fu, &moved, &[], 1e-3, &mut EdgeBounds::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let found = local_extension_reach(&k, &brep, fu, &moved, &[], 1e-3)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
(found.reach, found.shift)
|
||||
};
|
||||
[at(Vec3::Z), at(-Vec3::Y), at(-Vec3::X), at(-Vec3::Z)]
|
||||
@@ -2728,17 +2542,9 @@ mod tests {
|
||||
n,
|
||||
));
|
||||
let moved = [(moved_face, &plane)];
|
||||
let found = local_extension_reach(
|
||||
&k,
|
||||
&brep,
|
||||
across,
|
||||
&moved,
|
||||
&[],
|
||||
1e-3,
|
||||
&mut EdgeBounds::default(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let found = local_extension_reach(&k, &brep, across, &moved, &[], 1e-3)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (reach, shift) = (found.reach, found.shift);
|
||||
assert!((reach - delta * sqrt(1.5)).abs() < 1e-12, "{reach}");
|
||||
assert!((shift - delta).abs() < 1e-12, "{shift}");
|
||||
@@ -2751,9 +2557,7 @@ mod tests {
|
||||
let mut kk = Kernel::default();
|
||||
let (brep, faceuses) = box_faceuses(&mut kk, false);
|
||||
for (_, fu) in faceuses {
|
||||
let found =
|
||||
local_extension_reach(&k, &brep, fu, &[], &[], 1e-3, &mut EdgeBounds::default())
|
||||
.unwrap();
|
||||
let found = local_extension_reach(&k, &brep, fu, &[], &[], 1e-3).unwrap();
|
||||
assert_eq!(found, Some(LocalReach::default()));
|
||||
}
|
||||
}
|
||||
@@ -2809,8 +2613,7 @@ mod tests {
|
||||
kept: &[(FaceId, &Surface)],
|
||||
) -> Option<OffsetFailure> {
|
||||
let fu = faceuse_of(b, f);
|
||||
let e = local_extension_reach(k, b, fu, moved, kept, 1e-3, &mut EdgeBounds::default())
|
||||
.unwrap_err();
|
||||
let e = local_extension_reach(k, b, fu, moved, kept, 1e-3).unwrap_err();
|
||||
match e.failure().map(|f| &f.reason) {
|
||||
Some(crate::error::Reason::Offset(why)) => Some(*why),
|
||||
_ => None,
|
||||
@@ -2819,17 +2622,9 @@ mod tests {
|
||||
|
||||
/// Each face's reach with `moved`.
|
||||
fn reach_of(k: &Kernel, b: &Brep, f: FaceId, moved: &[(FaceId, &Surface)]) -> LocalReach {
|
||||
local_extension_reach(
|
||||
k,
|
||||
b,
|
||||
faceuse_of(b, f),
|
||||
moved,
|
||||
&[],
|
||||
1e-3,
|
||||
&mut EdgeBounds::default(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
local_extension_reach(k, b, faceuse_of(b, f), moved, &[], 1e-3)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `x` is a certified upper bound of `want` within the B-rep's tolerance.
|
||||
@@ -2939,104 +2734,13 @@ mod tests {
|
||||
};
|
||||
let piece =
|
||||
|over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| quadric_distance_piece(over.0, mid.0, dt, &q);
|
||||
let (at_seam, apart) = edge_interval_maximum(
|
||||
&b,
|
||||
seam,
|
||||
&piece,
|
||||
b.tolerance(),
|
||||
&mut EdgeBounds::default(),
|
||||
EdgeQuantity::Shift(None),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (at_seam, apart) = edge_interval_maximum(&b, seam, &piece, b.tolerance())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(bounds(&b, at_seam, 1.0), "{at_seam}");
|
||||
assert!(!apart);
|
||||
}
|
||||
|
||||
/// An edge's bound is taken from [`EdgeBounds`] only for the same quantity on the same
|
||||
/// inputs: the seam of a cylinder r 1 against the cylinder r 2, its pieces counted. The
|
||||
/// second ask gives the first's bits and bounds no piece; another surface, tolerance or
|
||||
/// quantity bounds the edge again, as does an ask after a clear or with no surfaces.
|
||||
#[test]
|
||||
fn an_edge_bound_is_reused_only_on_the_same_inputs() {
|
||||
let mut k = Kernel::default();
|
||||
let (b, side, _, seam, wider) = seamed_cylinder(&mut k, 2.0);
|
||||
let c = exact_class(&k, &wider).unwrap().unwrap();
|
||||
let Exact::Quadric(q) = c else {
|
||||
panic!("the cylinder r 2 is a quadric");
|
||||
};
|
||||
let calls = std::cell::Cell::new(0_usize);
|
||||
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
|
||||
calls.set(calls.get() + 1);
|
||||
quadric_distance_piece(over.0, mid.0, dt, &q)
|
||||
};
|
||||
let ask = |bounds: &mut EdgeBounds, tol: f64, of: EdgeQuantity, on: Option<&[u64]>| {
|
||||
let before = calls.get();
|
||||
let found = edge_interval_maximum(&b, seam, &piece, tol, bounds, of, on)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
(found.0.to_bits(), found.1, calls.get() - before)
|
||||
};
|
||||
let surfaces = exact_bits(&c).unwrap();
|
||||
let mut other = surfaces.clone();
|
||||
*other.last_mut().unwrap() ^= 1;
|
||||
let (tol, shift) = (b.tolerance(), EdgeQuantity::Shift(Some(side)));
|
||||
let mut held = EdgeBounds::default();
|
||||
let first = ask(&mut held, tol, shift, Some(&surfaces));
|
||||
assert!(first.2 > 0, "{first:?}");
|
||||
assert_eq!(
|
||||
ask(&mut held, tol, shift, Some(&surfaces)),
|
||||
(first.0, first.1, 0)
|
||||
);
|
||||
assert!(ask(&mut held, tol, shift, Some(&other)).2 > 0);
|
||||
assert_eq!(ask(&mut held, tol, shift, Some(&other)).2, 0);
|
||||
assert!(ask(&mut held, 2.0 * tol, shift, Some(&other)).2 > 0);
|
||||
let none = EdgeQuantity::Shift(None);
|
||||
assert!(ask(&mut held, tol, none, Some(&surfaces)).2 > 0);
|
||||
assert_eq!(
|
||||
ask(&mut held, tol, none, Some(&surfaces)),
|
||||
(first.0, first.1, 0)
|
||||
);
|
||||
held.clear();
|
||||
assert_eq!(held.held(), 0);
|
||||
assert_eq!(ask(&mut held, tol, none, Some(&surfaces)), first);
|
||||
for _ in 0..2 {
|
||||
assert_eq!(ask(&mut EdgeBounds::default(), tol, shift, None), first);
|
||||
}
|
||||
let mut unrecorded = EdgeBounds::default();
|
||||
assert_eq!(ask(&mut unrecorded, tol, shift, None), first);
|
||||
assert_eq!(unrecorded.held(), 0);
|
||||
}
|
||||
|
||||
/// What a bound is recorded on ([`bound_inputs`]) tells curves apart by their bits: a
|
||||
/// line from 0 to 1 against the same line with its first knot −0 (equal as numbers), a
|
||||
/// control point moved, or a weight changed; the same curve gives the same bits.
|
||||
#[test]
|
||||
fn a_changed_curve_is_another_bound() {
|
||||
let line = |u0: f64, x1: f64, w1: f64| NurbsCurve {
|
||||
p: 1,
|
||||
n: 2,
|
||||
u: vec![u0, 0.0, 1.0, 1.0],
|
||||
pw: vec![0.0, 0.0, 0.0, 1.0, x1 * w1, 0.0, 0.0, w1],
|
||||
};
|
||||
let interval = crate::geom::extent1d::Extent1d::from_raw(0.0, 1.0);
|
||||
let on = |c: &NurbsCurve| bound_inputs(c, interval, 1e-5, &[7]);
|
||||
let base = on(&line(0.0, 1.0, 1.0));
|
||||
assert_eq!(on(&line(0.0, 1.0, 1.0)), base);
|
||||
for other in [
|
||||
line(-0.0, 1.0, 1.0),
|
||||
line(0.0, 1.5, 1.0),
|
||||
line(0.0, 1.0, 2.0),
|
||||
] {
|
||||
assert_ne!(on(&other), base, "{other:?}");
|
||||
}
|
||||
assert_ne!(
|
||||
bound_inputs(&line(0.0, 1.0, 1.0), interval, 1e-5, &[8]),
|
||||
base
|
||||
);
|
||||
}
|
||||
|
||||
/// The side of a cylinder r 1 given the coaxial cylinder one rounding wider: every
|
||||
/// face is reached with no refusal, by a certified bound (the true meetings are a
|
||||
/// rounding past the circles: the bound stays within the B-rep's tolerance of that
|
||||
@@ -3049,17 +2753,9 @@ mod tests {
|
||||
let (b, side, caps, _, wider) = seamed_cylinder(&mut k, r);
|
||||
let moved = [(side, &wider)];
|
||||
for f in [side, caps[0], caps[1]] {
|
||||
let found = local_extension_reach(
|
||||
&k,
|
||||
&b,
|
||||
faceuse_of(&b, f),
|
||||
&moved,
|
||||
&[],
|
||||
1e-3,
|
||||
&mut EdgeBounds::default(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let found = local_extension_reach(&k, &b, faceuse_of(&b, f), &moved, &[], 1e-3)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(found.reach >= 0.0 && found.reach < 1e-3, "{f:?}: {found:?}");
|
||||
assert!(bounds(&b, found.shift, 0.0), "{f:?}: {found:?}");
|
||||
assert!(found.apart.is_empty(), "{f:?}: {found:?}");
|
||||
@@ -3110,10 +2806,9 @@ mod tests {
|
||||
let slack = b.tolerance() + 1e-9;
|
||||
for f in [bottom, round] {
|
||||
let fu = faceuse_of(&b, f);
|
||||
let found =
|
||||
local_extension_reach(&k, &b, fu, &moved, &[], 1e-3, &mut EdgeBounds::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let found = local_extension_reach(&k, &b, fu, &moved, &[], 1e-3)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
for (name, x) in [("reach", found.reach), ("move", found.shift)] {
|
||||
assert!(
|
||||
x >= want - 1e-9 && x <= want + slack,
|
||||
@@ -3123,10 +2818,9 @@ mod tests {
|
||||
assert!(found.apart.is_empty());
|
||||
}
|
||||
let fu = faceuse_of(&b, side);
|
||||
let found =
|
||||
local_extension_reach(&k, &b, fu, &moved, &[], 1e-3, &mut EdgeBounds::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let found = local_extension_reach(&k, &b, fu, &moved, &[], 1e-3)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(found.reach <= slack && found.shift <= slack, "{found:?}");
|
||||
}
|
||||
|
||||
@@ -3294,17 +2988,9 @@ mod tests {
|
||||
));
|
||||
let moved = [(piece, &raised)];
|
||||
let at = |f: FaceId| {
|
||||
local_extension_reach(
|
||||
&k,
|
||||
&b,
|
||||
faceuse_of(&b, f),
|
||||
&moved,
|
||||
&[],
|
||||
1e-3,
|
||||
&mut EdgeBounds::default(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
local_extension_reach(&k, &b, faceuse_of(&b, f), &moved, &[], 1e-3)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
let stays = at(twin);
|
||||
assert_eq!(stays.apart.len(), 1, "{stays:?}");
|
||||
@@ -3323,9 +3009,7 @@ mod tests {
|
||||
let (b, _, _, _) = rounded_cube(&mut k);
|
||||
for f in bp1::faces(&b, None).unwrap() {
|
||||
let fu = faceuse_of(&b, f);
|
||||
let found =
|
||||
local_extension_reach(&k, &b, fu, &[], &[], 1e-3, &mut EdgeBounds::default())
|
||||
.unwrap();
|
||||
let found = local_extension_reach(&k, &b, fu, &[], &[], 1e-3).unwrap();
|
||||
assert_eq!(found, Some(LocalReach::default()));
|
||||
}
|
||||
}
|
||||
@@ -3357,17 +3041,9 @@ mod tests {
|
||||
let moved = [(side, &into)];
|
||||
let kept = [(round, &net)];
|
||||
for f in [side, round, bottom] {
|
||||
let found = local_extension_reach(
|
||||
&k,
|
||||
&b,
|
||||
faceuse_of(&b, f),
|
||||
&moved,
|
||||
&kept,
|
||||
1e-3,
|
||||
&mut EdgeBounds::default(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let found = local_extension_reach(&k, &b, faceuse_of(&b, f), &moved, &kept, 1e-3)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(bounds(&b, found.reach, 1.0), "{f:?}: {found:?}");
|
||||
assert!(bounds(&b, found.shift, 0.1), "{f:?}: {found:?}");
|
||||
assert!(found.apart.is_empty(), "{f:?}: {found:?}");
|
||||
@@ -3405,17 +3081,9 @@ mod tests {
|
||||
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
|
||||
plane_net_piece(over, mid, dt, into, &net, &guess)
|
||||
};
|
||||
let (reach, apart) = edge_interval_maximum(
|
||||
&b,
|
||||
tangent,
|
||||
&piece,
|
||||
b.tolerance(),
|
||||
&mut EdgeBounds::default(),
|
||||
EdgeQuantity::Shift(None),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (reach, apart) = edge_interval_maximum(&b, tangent, &piece, b.tolerance())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(bounds(&b, reach, 1.0) && !apart, "{reach}");
|
||||
}
|
||||
|
||||
@@ -3440,18 +3108,10 @@ mod tests {
|
||||
let moved = [(bottom, &tilted)];
|
||||
let kept = [(round, &net)];
|
||||
let reach = |f: FaceId| {
|
||||
local_extension_reach(
|
||||
&k,
|
||||
&b,
|
||||
faceuse_of(&b, f),
|
||||
&moved,
|
||||
&kept,
|
||||
1e-3,
|
||||
&mut EdgeBounds::default(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.reach
|
||||
local_extension_reach(&k, &b, faceuse_of(&b, f), &moved, &kept, 1e-3)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.reach
|
||||
};
|
||||
let at_round = reach(round);
|
||||
assert!(bounds(&b, at_round, 0.45), "{at_round}");
|
||||
@@ -3491,17 +3151,9 @@ mod tests {
|
||||
let piece = |over: (Dv, Dv), mid: (Dv, Dv), dt: Ivl| {
|
||||
plane_net_piece(over, mid, dt, tilted, &net, &guess)
|
||||
};
|
||||
let (reach, apart) = edge_interval_maximum(
|
||||
&b,
|
||||
arc,
|
||||
&piece,
|
||||
b.tolerance(),
|
||||
&mut EdgeBounds::default(),
|
||||
EdgeQuantity::Shift(None),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let (reach, apart) = edge_interval_maximum(&b, arc, &piece, b.tolerance())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(bounds(&b, reach, 0.45) && !apart, "{reach}");
|
||||
}
|
||||
|
||||
@@ -3534,66 +3186,4 @@ mod tests {
|
||||
assert_eq!(xs(&left), vec![0.0, 0.5, 1.5]);
|
||||
assert_eq!(xs(&right), vec![1.5, 2.5, 4.0]);
|
||||
}
|
||||
|
||||
/// A meeting's distance takes the trace direction's length: the unit sphere and the line
|
||||
/// `x0 + τ u` through its centre with `u = (2, 0, 0)` (far from unit, to show the
|
||||
/// factor; a trace's own `u` is unit within rounding), its edge point `f` off the centre.
|
||||
/// The roots `τ = ±½` are the points `(±1, 0, 0)`, `√(f² + 1)` from the edge point; as
|
||||
/// if `|u| = 1` the bound would be `√(f² + ¼)`, below it.
|
||||
#[test]
|
||||
fn a_meeting_is_as_far_as_its_point_whatever_the_direction_length() {
|
||||
let sphere = Implicit::Sphere {
|
||||
centre: Vec3::new(0.0, 0.0, 0.0),
|
||||
radius: 1.0,
|
||||
};
|
||||
for f in [0.0, 0.5, 3.0] {
|
||||
let line = Trace {
|
||||
x0: Dv::constant(Vec3::new(0.0, 0.0, 0.0)),
|
||||
foot2: Di::constant(f * f),
|
||||
u: Dv::constant(Vec3::new(2.0, 0.0, 0.0)),
|
||||
};
|
||||
let q = along_line(&sphere, &line).unwrap();
|
||||
let plan = plan_of(&line, &q, &sphere, 1e-5);
|
||||
assert!(matches!(plan, Plan::Roots { .. }), "{plan:?}");
|
||||
let found = planned(&plan, &line, &q).unwrap();
|
||||
assert_eq!(found.len(), 2, "{found:?}");
|
||||
let exact = f * f + 1.0;
|
||||
for g in found {
|
||||
assert!(g.v.lo <= exact && exact <= g.v.hi, "{f}: {g:?} {exact}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line's meeting with a plane is as far as its point, whatever the direction's
|
||||
/// length: the line through the origin along `u = (2, 0, 0)` (far from unit, to show
|
||||
/// it; an edge's own `u` is unit within rounding) meets the plane `x = 3` at `(3, 0, 0)`,
|
||||
/// `√5` from `p = (1, 1, 0)`; `x0 = (4, 0, 0)` is a point of the line but not `p`'s foot.
|
||||
/// A curve `dev` off the line, which is square to the plane, adds `dev` (the sine is
|
||||
/// `|u·ν| / (|u| |ν|)` = 1), not `dev / 2`.
|
||||
#[test]
|
||||
fn a_line_meets_a_plane_as_far_as_its_point_whatever_the_direction_length() {
|
||||
let plane = Tangent {
|
||||
point: Vec3::new(3.0, 0.0, 0.0),
|
||||
normal: Vec3::new(1.0, 0.0, 0.0),
|
||||
raw: Vec3::new(1.0, 0.0, 0.0),
|
||||
};
|
||||
for dev in [0.0, 0.1] {
|
||||
let met = line_meets_plane(
|
||||
Dv::constant(Vec3::new(1.0, 1.0, 0.0)),
|
||||
Dv::constant(Vec3::new(0.0, 0.0, 0.0)),
|
||||
Dv::constant(Vec3::new(2.0, 0.0, 0.0)),
|
||||
dev,
|
||||
plane,
|
||||
1e-3,
|
||||
);
|
||||
let exact = 5.0_f64.sqrt() + dev;
|
||||
let CornerMeeting::Within(reach) = met else {
|
||||
panic!("{dev}: {met:?}");
|
||||
};
|
||||
assert!(
|
||||
reach >= exact && reach <= exact * (1.0 + 1e-12),
|
||||
"{dev}: {reach} {exact}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-41
@@ -19,7 +19,6 @@
|
||||
//! | [`PrimitiveCreation::create_sphere_no_pole`] | `CreateSphereNoPole` |
|
||||
//! | [`PrimitiveCreation::create_cone_elliptic_ends`] | `CreateConeEllipticEnds` |
|
||||
//! | [`analytic_rotational_sweep`] | `sm_CreateAnalyticRotationalSweep` |
|
||||
//! | [`coplanar_with_axis`] | (not upstream, WR-776) |
|
||||
//!
|
||||
//! Deviations: missing topology edits return Unported at their first call.
|
||||
//! Mutations after that call are deferred; no success is claimed for those paths.
|
||||
@@ -27,11 +26,6 @@
|
||||
//! them. `SmBrep` is caller-owned; edits retain upstream edit-mode side effects.
|
||||
//! Face construction uses caller notification records, attributes and identity keys.
|
||||
//! No recursion.
|
||||
//! Not upstream (WR-776): [`analytic_rotational_sweep`] makes its cone only for a line in
|
||||
//! one plane with the axis ([`coplanar_with_axis`]); upstream makes the cone through the
|
||||
//! end circles of a line skew to the axis too, which sweeps a hyperboloid of one sheet. A
|
||||
//! skew line is refused here (a described failure, `SM_ERR` status) before any edit, so
|
||||
//! the caller gives it its surface of revolution.
|
||||
//! Not upstream (WR-565, tangency-labs/solidcore#1477, opt-in
|
||||
//! `Kernel::fixes.linear_sweep_arc_is_cylinder`; SolidCraft change, tangency-labs/smlib
|
||||
//! 12918e9): [`PrimitiveCreation::create_linear_sweep`] makes a circular arc short of a
|
||||
@@ -1437,35 +1431,9 @@ impl PrimitiveCreation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the segment from `start` to `end` lies in one plane with the axis through
|
||||
/// `axis_base` along `axis`, within `tol`: the lines are parallel (the sine of their
|
||||
/// angle within [`EFF_ZERO`]), or their common perpendicular is no longer than `tol` (one
|
||||
/// not finite is not). Only such a line, neither parallel nor square to the axis, sweeps a
|
||||
/// cone; a skew one sweeps a hyperboloid of one sheet, its radius about the axis the root
|
||||
/// of a quadratic in the axial coordinate, not linear in it (WR-776).
|
||||
#[must_use]
|
||||
pub(crate) fn coplanar_with_axis(
|
||||
axis_base: crate::math::Point3,
|
||||
axis: Vec3,
|
||||
start: crate::math::Point3,
|
||||
end: crate::math::Point3,
|
||||
tol: f64,
|
||||
) -> bool {
|
||||
let line = end - start;
|
||||
let normal = line.cross(axis);
|
||||
let sine = normal.length();
|
||||
if sine <= EFF_ZERO * line.length() * axis.length() {
|
||||
return true;
|
||||
}
|
||||
((start - axis_base).dot(normal) / sine).abs() <= tol
|
||||
}
|
||||
|
||||
/// Create an analytic rotational sweep (`sm_CreateAnalyticRotationalSweep`).
|
||||
/// Lines on the axis, interior axis crossings and unsuitable circular arcs return
|
||||
/// `SM_ERR`; topology construction defers at its exact owning unit. Not upstream
|
||||
/// (WR-776): a line skew to the axis and neither parallel nor square to it
|
||||
/// ([`coplanar_with_axis`] at the B-rep's tolerance) fails, described, before any edit,
|
||||
/// where upstream makes it the cone through its end circles.
|
||||
/// `SM_ERR`; topology construction defers at its exact owning unit.
|
||||
pub fn analytic_rotational_sweep(
|
||||
k: &mut Kernel,
|
||||
brep: &mut Brep,
|
||||
@@ -1546,14 +1514,6 @@ pub fn analytic_rotational_sweep(
|
||||
)),
|
||||
};
|
||||
} else {
|
||||
// Not upstream (WR-776): a line skew to the axis sweeps a hyperboloid, not the
|
||||
// cone through its end circles
|
||||
if !coplanar_with_axis(axis_base, axis, ends.start, ends.end, tol) {
|
||||
return Err(SolidError::primitive_failed(
|
||||
op,
|
||||
"the line is skew to the axis: it sweeps a hyperboloid, not a cone",
|
||||
));
|
||||
}
|
||||
let (bottom_radius, bottom_parameter) =
|
||||
line_point_distance(axis_base, axis, ends.start);
|
||||
let (top_radius, top_parameter) = line_point_distance(axis_base, axis, ends.end);
|
||||
|
||||
@@ -703,69 +703,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// WR-776: a line skew to the axis sweeps a hyperboloid of one sheet. Every point of
|
||||
/// the result is as far from the axis as the profile's point at the same axial
|
||||
/// coordinate, both measured here from the profile's and the axis's own numbers.
|
||||
/// Upstream's one-curve special case made the cone through the end circles, 3.85 mm
|
||||
/// off its hyperboloid half way along this line; with the analytic-faces fix or
|
||||
/// without, the line is its surface of revolution.
|
||||
#[test]
|
||||
fn a_line_skew_to_the_axis_revolves_onto_its_hyperboloid() {
|
||||
let (p0, p1) = ([600.0, 0.0, 0.0], [700.0, 0.0, 200.0]);
|
||||
let o = [500.0, -50.0, 0.0];
|
||||
let n = (40.0_f64 * 40.0 + 80.0 * 80.0 + 300.0 * 300.0).sqrt();
|
||||
let a = [40.0 / n, 80.0 / n, 300.0 / n];
|
||||
let dot = |u: [f64; 3], v: [f64; 3]| u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
|
||||
// the axial coordinate of p and its distance from the axis
|
||||
let polar = |p: [f64; 3]| {
|
||||
let q = [p[0] - o[0], p[1] - o[1], p[2] - o[2]];
|
||||
let h = dot(q, a);
|
||||
(h, (dot(q, q) - h * h).max(0.0).sqrt())
|
||||
};
|
||||
let ((h0, _), (h1, _)) = (polar(p0), polar(p1));
|
||||
let profile = segment(p0, p1);
|
||||
for analytic in [false, true] {
|
||||
let mut k = Kernel::default();
|
||||
k.fixes.revolve_analytic_profile_faces = analytic;
|
||||
let sheet = create_rotational_sweep(
|
||||
&mut k,
|
||||
&[Some(&profile)],
|
||||
Point3::new(o[0], o[1], o[2]),
|
||||
Vec3::new(a[0], a[1], a[2]),
|
||||
360.0,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let faces = crate::topology::brep::p1::faces(&sheet, None).unwrap();
|
||||
assert!(!faces.is_empty());
|
||||
let mut worst = 0.0_f64;
|
||||
for f in faces {
|
||||
let surface = crate::topology::face::p2::surface(&sheet, f)
|
||||
.unwrap()
|
||||
.expect("a surface");
|
||||
let d = surface.natural_uv_domain_with_nurb(&k).unwrap();
|
||||
for i in 0..=24 {
|
||||
for j in 0..=24 {
|
||||
let uv = crate::geom::vector2d::Point2::new(
|
||||
d.u_min() + (d.u_max() - d.u_min()) * f64::from(i) / 24.0,
|
||||
d.v_min() + (d.v_max() - d.v_min()) * f64::from(j) / 24.0,
|
||||
);
|
||||
let x = surface.evaluate_point(&k, uv).unwrap();
|
||||
let (h, r) = polar([x.x, x.y, x.z]);
|
||||
let t = (h - h0) / (h1 - h0);
|
||||
assert!((-1e-9..=1.0 + 1e-9).contains(&t), "past the profile: {x:?}");
|
||||
let on = [0, 1, 2].map(|c| p0[c] + t * (p1[c] - p0[c]));
|
||||
worst = worst.max((r - polar(on).1).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
worst <= 1e-6,
|
||||
"{worst} mm off the hyperboloid (fix {analytic})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A full turn of a half annulus about its diameter is a hollow ball with the
|
||||
/// opt-in; upstream makes the inner ball a second solid.
|
||||
#[test]
|
||||
|
||||
+8
-37
@@ -160,7 +160,7 @@ pub struct RailedLoft {
|
||||
/// A section curve as plain numbers: degree, full knots, Euclidean control points, weights
|
||||
/// (`None` for a polynomial curve).
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Plain {
|
||||
struct Plain {
|
||||
degree: usize,
|
||||
knots: Vec<f64>,
|
||||
points: Vec<Point3>,
|
||||
@@ -361,7 +361,7 @@ fn wire_point(curves: &[(Plain, NurbCurve)], g: f64) -> Result<Point3> {
|
||||
}
|
||||
|
||||
/// A station's curves, and per rail its parameter and point there.
|
||||
pub(crate) type StationAt = (Vec<Plain>, Vec<(f64, Point3)>);
|
||||
type StationAt = (Vec<Plain>, Vec<(f64, Point3)>);
|
||||
|
||||
/// An affine move: `x` goes to `to + m (x - from)`.
|
||||
struct Affine {
|
||||
@@ -710,11 +710,11 @@ impl<'a> Loft<'a> {
|
||||
|
||||
/// A wire of the loft: a section's curves or a station's, at loft parameter `(span, f)`, and
|
||||
/// per rail the rail's parameter and point there.
|
||||
pub(crate) struct Wire {
|
||||
pub(crate) span: usize,
|
||||
pub(crate) f: f64,
|
||||
pub(crate) curves: Vec<Plain>,
|
||||
pub(crate) hits: Vec<(f64, Point3)>,
|
||||
struct Wire {
|
||||
span: usize,
|
||||
f: f64,
|
||||
curves: Vec<Plain>,
|
||||
hits: Vec<(f64, Point3)>,
|
||||
}
|
||||
|
||||
/// The loft through `sections` guided by `rails` (module docs).
|
||||
@@ -759,35 +759,6 @@ pub fn loft_rails(
|
||||
hits,
|
||||
});
|
||||
}
|
||||
skin_and_certify(
|
||||
k,
|
||||
wires,
|
||||
spans,
|
||||
rails,
|
||||
opts,
|
||||
&mut |k: &Kernel, i: usize, f: f64| loft.station(k, i, f),
|
||||
)
|
||||
}
|
||||
|
||||
/// The loft's skin-and-certificate loop (module docs, "Where the stations go, and the
|
||||
/// certificate"): from the wires `wires` (the sections, each with its rails' crossings, `spans`
|
||||
/// spans between them), a station placed by `station(k, span, f)` at each span's middle, the
|
||||
/// wires skinned, each rail bounded against the skin on every span between neighbouring wires,
|
||||
/// and a span whose bound is above the tolerance given a station at its middle, until every
|
||||
/// span is certified; past [`RailLoftOptions::max_wires`] refused, naming the bound reached.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`crate::error::sweep::SweepFailure::RailPastStationCap`] past the wire cap; those of
|
||||
/// `station`, the skin and the bound.
|
||||
pub(crate) fn skin_and_certify(
|
||||
k: &mut Kernel,
|
||||
mut wires: Vec<Wire>,
|
||||
spans: usize,
|
||||
rails: &[&Curve],
|
||||
opts: &RailLoftOptions,
|
||||
station: &mut dyn FnMut(&Kernel, usize, f64) -> Result<StationAt>,
|
||||
) -> Result<RailedLoft> {
|
||||
// one station at each span's middle
|
||||
let mut pending: Vec<(usize, f64)> = (0..spans).map(|i| (i, 0.5)).collect();
|
||||
// the largest bound the last skin proved (none before the first)
|
||||
@@ -805,7 +776,7 @@ pub(crate) fn skin_and_certify(
|
||||
.into_error());
|
||||
}
|
||||
for (i, f) in pending.drain(..) {
|
||||
let (curves, hits) = station(&*k, i, f)?;
|
||||
let (curves, hits) = loft.station(k, i, f)?;
|
||||
wires.push(Wire {
|
||||
span: i,
|
||||
f,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user